This commit is contained in:
Aditya Siregar 2025-08-16 20:37:02 +07:00
parent de60983e4e
commit 1964fe50de
45 changed files with 1732 additions and 251 deletions

130
MIGRATION_SUMMARY.md Normal file
View File

@ -0,0 +1,130 @@
# Table Restructuring Summary
## Overview
This document summarizes the changes made to restructure the letter dispositions system from a single table to a more normalized structure with an association table.
## Changes Made
### 1. Database Schema Changes
#### New Migration Files Created:
- `migrations/000012_rename_dispositions_table.up.sql` - Main migration to restructure tables
- `migrations/000012_rename_dispositions_table.down.sql` - Rollback migration
#### Table Changes:
- **`letter_dispositions`** → **`letter_incoming_dispositions`**
- Renamed table
- Removed columns: `from_user_id`, `to_user_id`, `to_department_id`, `status`, `completed_at`
- Renamed `from_department_id``department_id`
- Added `read_at` column
- Kept columns: `id`, `letter_id`, `department_id`, `notes`, `read_at`, `created_at`, `created_by`, `updated_at`
#### New Table Created:
- **`letter_incoming_dispositions_department`**
- Purpose: Associates dispositions with target departments
- Columns: `id`, `letter_incoming_disposition_id`, `department_id`, `created_at`
- Unique constraint on `(letter_incoming_disposition_id, department_id)`
### 2. Entity Changes
#### Updated Entities:
- **`LetterDisposition`** → **`LetterIncomingDisposition`**
- Simplified structure with only required fields
- New table name mapping
#### New Entity:
- **`LetterIncomingDispositionDepartment`**
- Represents the many-to-many relationship between dispositions and departments
### 3. Repository Changes
#### Updated Repositories:
- **`LetterDispositionRepository`** → **`LetterIncomingDispositionRepository`**
- Updated to work with new entity
#### New Repository:
- **`LetterIncomingDispositionDepartmentRepository`**
- Handles CRUD operations for the association table
- Methods: `CreateBulk`, `ListByDisposition`
### 4. Processor Changes
#### Updated Processor:
- **`LetterProcessorImpl`**
- Added new repository dependency
- Updated `CreateDispositions` method to:
- Create main disposition record
- Create department association records
- Maintain existing action selection functionality
### 5. Transformer Changes
#### Updated Transformer:
- **`DispositionsToContract`** function
- Updated to work with new entity structure
- Maps new fields: `DepartmentID`, `ReadAt`, `UpdatedAt`
- Removed old fields: `FromDepartmentID`, `ToDepartmentID`, `Status`
### 6. Contract Changes
#### Updated Contract:
- **`DispositionResponse`** struct
- Updated fields to match new entity structure
- Added `ReadAt` and `UpdatedAt` fields
- Replaced `FromDepartmentID` and `ToDepartmentID` with `DepartmentID`
### 7. Application Configuration Changes
#### Updated App Configuration:
- **`internal/app/app.go`**
- Updated repository initialization
- Added new repository dependency
- Updated processor initialization with new repository
## Migration Process
### Up Migration (000012_rename_dispositions_table.up.sql):
1. Rename `letter_dispositions` to `letter_incoming_dispositions`
2. Drop unnecessary columns
3. Rename `from_department_id` to `department_id`
4. Add missing columns (`read_at`, `updated_at`)
5. Create new association table
6. Update triggers and indexes
### Down Migration (000012_rename_dispositions_table.down.sql):
1. Drop association table
2. Restore removed columns
3. Rename `department_id` back to `from_department_id`
4. Restore old triggers and indexes
5. Rename table back to `letter_dispositions`
## Benefits of New Structure
1. **Normalization**: Separates disposition metadata from department associations
2. **Flexibility**: Allows multiple departments per disposition
3. **Cleaner Data Model**: Removes redundant fields and simplifies the main table
4. **Better Performance**: Smaller main table with focused indexes
5. **Easier Maintenance**: Clear separation of concerns
## Breaking Changes
- Table name change from `letter_dispositions` to `letter_incoming_dispositions`
- Entity structure changes (removed fields, renamed fields)
- Repository interface changes
- API response structure changes
## Testing Recommendations
1. Run migration on test database
2. Test disposition creation with new structure
3. Verify department associations are created correctly
4. Test existing functionality (action selections, notes)
5. Verify rollback migration works correctly
## Rollback Plan
If issues arise, the down migration will:
1. Restore the original table structure
2. Preserve all existing data
3. Remove the new association table
4. Restore original triggers and indexes

4
go.mod
View File

@ -45,7 +45,7 @@ require (
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
@ -64,7 +64,7 @@ require (
github.com/aws/aws-sdk-go v1.55.7
github.com/golang-jwt/jwt/v5 v5.2.3
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.8.4
github.com/stretchr/testify v1.10.0
go.uber.org/zap v1.21.0
golang.org/x/crypto v0.28.0
gorm.io/driver/postgres v1.5.0

6
go.sum
View File

@ -236,8 +236,9 @@ github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc=
github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -247,8 +248,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=

View File

@ -117,38 +117,40 @@ type repositories struct {
activityLogRepo *repository.LetterIncomingActivityLogRepository
dispositionRouteRepo *repository.DispositionRouteRepository
// new repos
letterDispositionRepo *repository.LetterDispositionRepository
letterDispActionSelRepo *repository.LetterDispositionActionSelectionRepository
dispositionNoteRepo *repository.DispositionNoteRepository
letterDiscussionRepo *repository.LetterDiscussionRepository
settingRepo *repository.AppSettingRepository
recipientRepo *repository.LetterIncomingRecipientRepository
departmentRepo *repository.DepartmentRepository
userDeptRepo *repository.UserDepartmentRepository
letterDispositionRepo *repository.LetterIncomingDispositionRepository
letterDispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository
letterDispActionSelRepo *repository.LetterDispositionActionSelectionRepository
dispositionNoteRepo *repository.DispositionNoteRepository
letterDiscussionRepo *repository.LetterDiscussionRepository
settingRepo *repository.AppSettingRepository
recipientRepo *repository.LetterIncomingRecipientRepository
departmentRepo *repository.DepartmentRepository
userDeptRepo *repository.UserDepartmentRepository
}
func (a *App) initRepositories() *repositories {
return &repositories{
userRepo: repository.NewUserRepository(a.db),
userProfileRepo: repository.NewUserProfileRepository(a.db),
titleRepo: repository.NewTitleRepository(a.db),
rbacRepo: repository.NewRBACRepository(a.db),
labelRepo: repository.NewLabelRepository(a.db),
priorityRepo: repository.NewPriorityRepository(a.db),
institutionRepo: repository.NewInstitutionRepository(a.db),
dispRepo: repository.NewDispositionActionRepository(a.db),
letterRepo: repository.NewLetterIncomingRepository(a.db),
letterAttachRepo: repository.NewLetterIncomingAttachmentRepository(a.db),
activityLogRepo: repository.NewLetterIncomingActivityLogRepository(a.db),
dispositionRouteRepo: repository.NewDispositionRouteRepository(a.db),
letterDispositionRepo: repository.NewLetterDispositionRepository(a.db),
letterDispActionSelRepo: repository.NewLetterDispositionActionSelectionRepository(a.db),
dispositionNoteRepo: repository.NewDispositionNoteRepository(a.db),
letterDiscussionRepo: repository.NewLetterDiscussionRepository(a.db),
settingRepo: repository.NewAppSettingRepository(a.db),
recipientRepo: repository.NewLetterIncomingRecipientRepository(a.db),
departmentRepo: repository.NewDepartmentRepository(a.db),
userDeptRepo: repository.NewUserDepartmentRepository(a.db),
userRepo: repository.NewUserRepository(a.db),
userProfileRepo: repository.NewUserProfileRepository(a.db),
titleRepo: repository.NewTitleRepository(a.db),
rbacRepo: repository.NewRBACRepository(a.db),
labelRepo: repository.NewLabelRepository(a.db),
priorityRepo: repository.NewPriorityRepository(a.db),
institutionRepo: repository.NewInstitutionRepository(a.db),
dispRepo: repository.NewDispositionActionRepository(a.db),
letterRepo: repository.NewLetterIncomingRepository(a.db),
letterAttachRepo: repository.NewLetterIncomingAttachmentRepository(a.db),
activityLogRepo: repository.NewLetterIncomingActivityLogRepository(a.db),
dispositionRouteRepo: repository.NewDispositionRouteRepository(a.db),
letterDispositionRepo: repository.NewLetterIncomingDispositionRepository(a.db),
letterDispositionDeptRepo: repository.NewLetterIncomingDispositionDepartmentRepository(a.db),
letterDispActionSelRepo: repository.NewLetterDispositionActionSelectionRepository(a.db),
dispositionNoteRepo: repository.NewDispositionNoteRepository(a.db),
letterDiscussionRepo: repository.NewLetterDiscussionRepository(a.db),
settingRepo: repository.NewAppSettingRepository(a.db),
recipientRepo: repository.NewLetterIncomingRecipientRepository(a.db),
departmentRepo: repository.NewDepartmentRepository(a.db),
userDeptRepo: repository.NewUserDepartmentRepository(a.db),
}
}
@ -163,7 +165,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
activity := processor.NewActivityLogProcessor(repos.activityLogRepo)
return &processors{
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo),
letterProcessor: processor.NewLetterProcessor(repos.letterRepo, repos.letterAttachRepo, txMgr, activity, repos.letterDispositionRepo, repos.letterDispActionSelRepo, repos.dispositionNoteRepo, repos.letterDiscussionRepo, repos.settingRepo, repos.recipientRepo, repos.departmentRepo, repos.userDeptRepo),
letterProcessor: processor.NewLetterProcessor(repos.letterRepo, repos.letterAttachRepo, txMgr, activity, repos.letterDispositionRepo, repos.letterDispositionDeptRepo, repos.letterDispActionSelRepo, repos.dispositionNoteRepo, repos.letterDiscussionRepo, repos.settingRepo, repos.recipientRepo, repos.departmentRepo, repos.userDeptRepo, repos.priorityRepo, repos.institutionRepo, repos.dispRepo),
activityLogger: activity,
}
}

View File

@ -12,7 +12,7 @@ const (
CorrelationIDKey = key("CorrelationID")
OrganizationIDKey = key("OrganizationIDKey")
UserIDKey = key("UserID")
OutletIDKey = key("OutletID")
DepartmentIDKey = key("DepartmentID")
RoleIDKey = key("RoleID")
AppVersionKey = key("AppVersion")
AppIDKey = key("AppID")
@ -27,7 +27,7 @@ func LogFields(ctx interface{}) map[string]interface{} {
fields := make(map[string]interface{})
fields[string(CorrelationIDKey)] = value(ctx, CorrelationIDKey)
fields[string(OrganizationIDKey)] = value(ctx, OrganizationIDKey)
fields[string(OutletIDKey)] = value(ctx, OutletIDKey)
fields[string(DepartmentIDKey)] = value(ctx, DepartmentIDKey)
fields[string(AppVersionKey)] = value(ctx, AppVersionKey)
fields[string(AppIDKey)] = value(ctx, AppIDKey)
fields[string(AppTypeKey)] = value(ctx, AppTypeKey)

View File

@ -17,17 +17,16 @@ type Logger struct {
var log *Logger
type ContextInfo struct {
CorrelationID string
UserID uuid.UUID
OrganizationID uuid.UUID
OutletID uuid.UUID
AppVersion string
AppID string
AppType string
Platform string
DeviceOS string
UserLocale string
UserRole string
CorrelationID string
UserID uuid.UUID
DepartmentID uuid.UUID
AppVersion string
AppID string
AppType string
Platform string
DeviceOS string
UserLocale string
UserRole string
}
type ctxKeyType struct{}
@ -59,17 +58,16 @@ func NewContext(ctx context.Context, baseFields map[string]interface{}) context.
func FromGinContext(ctx context.Context) *ContextInfo {
return &ContextInfo{
CorrelationID: value(ctx, CorrelationIDKey),
UserID: uuidValue(ctx, UserIDKey),
OutletID: uuidValue(ctx, OutletIDKey),
OrganizationID: uuidValue(ctx, OrganizationIDKey),
AppVersion: value(ctx, AppVersionKey),
AppID: value(ctx, AppIDKey),
AppType: value(ctx, AppTypeKey),
Platform: value(ctx, PlatformKey),
DeviceOS: value(ctx, DeviceOSKey),
UserLocale: value(ctx, UserLocaleKey),
UserRole: value(ctx, UserRoleKey),
CorrelationID: value(ctx, CorrelationIDKey),
UserID: uuidValue(ctx, UserIDKey),
DepartmentID: uuidValue(ctx, DepartmentIDKey),
AppVersion: value(ctx, AppVersionKey),
AppID: value(ctx, AppIDKey),
AppType: value(ctx, AppTypeKey),
Platform: value(ctx, PlatformKey),
DeviceOS: value(ctx, DeviceOSKey),
UserLocale: value(ctx, UserLocaleKey),
UserRole: value(ctx, UserRoleKey),
}
}

View File

@ -9,7 +9,7 @@ const (
XAppIDHeader = "x-appid"
XPhoneModelHeader = "X-PhoneModel"
OrganizationID = "x_organization_id"
OutletID = "x_owner_id"
DepartmentID = "x_department_id"
CountryCodeHeader = "country-code"
AcceptedLanguageHeader = "accept-language"
XUserLocaleHeader = "x-user-locale"

View File

@ -132,6 +132,10 @@ type ListInstitutionsResponse struct {
Institutions []InstitutionResponse `json:"institutions"`
}
type ListInstitutionsRequest struct {
Search *string `json:"search,omitempty" form:"search"`
}
type DispositionActionResponse struct {
ID string `json:"id"`
Code string `json:"code"`

View File

@ -6,6 +6,12 @@ import (
"github.com/google/uuid"
)
type DepartmentInfo struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Code string `json:"code,omitempty"`
}
type DispositionRouteResponse struct {
ID uuid.UUID `json:"id"`
FromDepartmentID uuid.UUID `json:"from_department_id"`
@ -14,6 +20,10 @@ type DispositionRouteResponse struct {
AllowedActions map[string]interface{} `json:"allowed_actions,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Department information
FromDepartment DepartmentInfo `json:"from_department"`
ToDepartment DepartmentInfo `json:"to_department"`
}
type CreateDispositionRouteRequest struct {

View File

@ -32,20 +32,20 @@ type IncomingLetterAttachmentResponse struct {
}
type IncomingLetterResponse struct {
ID uuid.UUID `json:"id"`
LetterNumber string `json:"letter_number"`
ReferenceNumber *string `json:"reference_number,omitempty"`
Subject string `json:"subject"`
Description *string `json:"description,omitempty"`
PriorityID *uuid.UUID `json:"priority_id,omitempty"`
SenderInstitutionID *uuid.UUID `json:"sender_institution_id,omitempty"`
ReceivedDate time.Time `json:"received_date"`
DueDate *time.Time `json:"due_date,omitempty"`
Status string `json:"status"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Attachments []IncomingLetterAttachmentResponse `json:"attachments"`
ID uuid.UUID `json:"id"`
LetterNumber string `json:"letter_number"`
ReferenceNumber *string `json:"reference_number,omitempty"`
Subject string `json:"subject"`
Description *string `json:"description,omitempty"`
Priority *PriorityResponse `json:"priority,omitempty"`
SenderInstitution *InstitutionResponse `json:"sender_institution,omitempty"`
ReceivedDate time.Time `json:"received_date"`
DueDate *time.Time `json:"due_date,omitempty"`
Status string `json:"status"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Attachments []IncomingLetterAttachmentResponse `json:"attachments"`
}
type UpdateIncomingLetterRequest struct {
@ -77,6 +77,7 @@ type CreateDispositionActionSelection struct {
}
type CreateLetterDispositionRequest struct {
FromDepartment uuid.UUID `json:"from_department"`
LetterID uuid.UUID `json:"letter_id"`
ToDepartmentIDs []uuid.UUID `json:"to_department_ids"`
Notes *string `json:"notes,omitempty"`
@ -84,20 +85,64 @@ type CreateLetterDispositionRequest struct {
}
type DispositionResponse struct {
ID uuid.UUID `json:"id"`
LetterID uuid.UUID `json:"letter_id"`
FromDepartmentID *uuid.UUID `json:"from_department_id,omitempty"`
ToDepartmentID *uuid.UUID `json:"to_department_id,omitempty"`
Notes *string `json:"notes,omitempty"`
Status string `json:"status"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
ID uuid.UUID `json:"id"`
LetterID uuid.UUID `json:"letter_id"`
DepartmentID *uuid.UUID `json:"department_id,omitempty"`
Notes *string `json:"notes,omitempty"`
ReadAt *time.Time `json:"read_at,omitempty"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListDispositionsResponse struct {
Dispositions []DispositionResponse `json:"dispositions"`
}
type EnhancedDispositionResponse struct {
ID uuid.UUID `json:"id"`
LetterID uuid.UUID `json:"letter_id"`
DepartmentID *uuid.UUID `json:"department_id,omitempty"`
Notes *string `json:"notes,omitempty"`
ReadAt *time.Time `json:"read_at,omitempty"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Department DepartmentResponse `json:"department"`
Departments []DispositionDepartmentResponse `json:"departments"`
Actions []DispositionActionSelectionResponse `json:"actions"`
DispositionNotes []DispositionNoteResponse `json:"disposition_notes"`
}
type DispositionDepartmentResponse struct {
ID uuid.UUID `json:"id"`
DepartmentID uuid.UUID `json:"department_id"`
CreatedAt time.Time `json:"created_at"`
Department *DepartmentResponse `json:"department,omitempty"`
}
type DispositionActionSelectionResponse struct {
ID uuid.UUID `json:"id"`
ActionID uuid.UUID `json:"action_id"`
Action *DispositionActionResponse `json:"action,omitempty"`
Note *string `json:"note,omitempty"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
type DispositionNoteResponse struct {
ID uuid.UUID `json:"id"`
UserID *uuid.UUID `json:"user_id,omitempty"`
Note string `json:"note"`
CreatedAt time.Time `json:"created_at"`
User *UserResponse `json:"user,omitempty"`
}
type ListEnhancedDispositionsResponse struct {
Dispositions []EnhancedDispositionResponse `json:"dispositions"`
Discussions []LetterDiscussionResponse `json:"discussions"`
}
type CreateLetterDiscussionRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Message string `json:"message"`
@ -119,4 +164,10 @@ type LetterDiscussionResponse struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
EditedAt *time.Time `json:"edited_at,omitempty"`
// Preloaded user profile who created the discussion
User *UserResponse `json:"user,omitempty"`
// Preloaded user profiles for mentions
MentionedUsers []UserResponse `json:"mentioned_users,omitempty"`
}

View File

@ -48,14 +48,15 @@ type LoginResponse struct {
}
type UserResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Roles []RoleResponse `json:"roles,omitempty"`
Profile *UserProfileResponse `json:"profile,omitempty"`
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Roles []RoleResponse `json:"roles,omitempty"`
DepartmentResponse []DepartmentResponse `json:"department_response"`
Profile *UserProfileResponse `json:"profile,omitempty"`
}
type ListUsersRequest struct {
@ -128,3 +129,15 @@ type TitleResponse struct {
type ListTitlesResponse struct {
Titles []TitleResponse `json:"titles"`
}
// MentionUsersRequest represents the request for getting users for mention purposes
type MentionUsersRequest struct {
Search *string `json:"search,omitempty" form:"search"` // Optional search term for username
Limit *int `json:"limit,omitempty" form:"limit"` // Optional limit, defaults to 50, max 100
}
// MentionUsersResponse represents the response for getting users for mention purposes
type MentionUsersResponse struct {
Users []UserResponse `json:"users"`
Count int `json:"count"`
}

View File

@ -14,6 +14,10 @@ type DispositionRoute struct {
AllowedActions JSONB `gorm:"type:jsonb" json:"allowed_actions,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
// Relationships
FromDepartment Department `gorm:"foreignKey:FromDepartmentID;references:ID" json:"from_department,omitempty"`
ToDepartment Department `gorm:"foreignKey:ToDepartmentID;references:ID" json:"to_department,omitempty"`
}
func (DispositionRoute) TableName() string { return "disposition_routes" }

View File

@ -16,6 +16,9 @@ type LetterDiscussion struct {
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
EditedAt *time.Time `json:"edited_at,omitempty"`
// Relationships
User *User `gorm:"foreignKey:UserID;references:ID" json:"user,omitempty"`
}
func (LetterDiscussion) TableName() string { return "letter_incoming_discussions" }

View File

@ -6,32 +6,36 @@ import (
"github.com/google/uuid"
)
type LetterDispositionStatus string
const (
DispositionPending LetterDispositionStatus = "pending"
DispositionRead LetterDispositionStatus = "read"
DispositionRejected LetterDispositionStatus = "rejected"
DispositionCompleted LetterDispositionStatus = "completed"
)
type LetterDisposition struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"`
FromUserID *uuid.UUID `json:"from_user_id,omitempty"`
FromDepartmentID *uuid.UUID `json:"from_department_id,omitempty"`
ToUserID *uuid.UUID `json:"to_user_id,omitempty"`
ToDepartmentID *uuid.UUID `json:"to_department_id,omitempty"`
Notes *string `json:"notes,omitempty"`
Status LetterDispositionStatus `gorm:"not null;default:'pending'" json:"status"`
CreatedBy uuid.UUID `gorm:"not null" json:"created_by"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
ReadAt *time.Time `json:"read_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
type LetterIncomingDisposition struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"`
DepartmentID *uuid.UUID `json:"department_id,omitempty"`
Notes *string `json:"notes,omitempty"`
ReadAt *time.Time `json:"read_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
CreatedBy uuid.UUID `gorm:"not null" json:"created_by"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Department Department `gorm:"foreignKey:DepartmentID;references:ID" json:"department,omitempty"`
Departments []LetterIncomingDispositionDepartment `gorm:"foreignKey:LetterIncomingDispositionID;references:ID" json:"departments,omitempty"`
ActionSelections []LetterDispositionActionSelection `gorm:"foreignKey:DispositionID;references:ID" json:"action_selections,omitempty"`
DispositionNotes []DispositionNote `gorm:"foreignKey:DispositionID;references:ID" json:"disposition_notes,omitempty"`
}
func (LetterDisposition) TableName() string { return "letter_dispositions" }
func (LetterIncomingDisposition) TableName() string { return "letter_incoming_dispositions" }
type LetterIncomingDispositionDepartment struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
LetterIncomingDispositionID uuid.UUID `gorm:"type:uuid;not null" json:"letter_incoming_disposition_id"`
DepartmentID uuid.UUID `gorm:"type:uuid;not null" json:"department_id"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
// Relationships
Department *Department `gorm:"foreignKey:DepartmentID;references:ID" json:"department,omitempty"`
}
func (LetterIncomingDispositionDepartment) TableName() string {
return "letter_incoming_dispositions_department"
}
type DispositionNote struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
@ -39,6 +43,9 @@ type DispositionNote struct {
UserID *uuid.UUID `json:"user_id,omitempty"`
Note string `gorm:"not null" json:"note"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
// Relationships
User *User `gorm:"foreignKey:UserID;references:ID" json:"user,omitempty"`
}
func (DispositionNote) TableName() string { return "disposition_notes" }
@ -50,6 +57,9 @@ type LetterDispositionActionSelection struct {
Note *string `json:"note,omitempty"`
CreatedBy uuid.UUID `gorm:"not null" json:"created_by"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
// Relationships
Action *DispositionAction `gorm:"foreignKey:ActionID;references:ID" json:"action,omitempty"`
}
func (LetterDispositionActionSelection) TableName() string { return "letter_disposition_actions" }

View File

@ -48,6 +48,7 @@ type User struct {
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Profile *UserProfile `gorm:"foreignKey:UserID;references:ID" json:"profile,omitempty"`
Departments []Department `gorm:"many2many:user_department;foreignKey:ID;joinForeignKey:user_id;References:ID;joinReferences:department_id" json:"departments,omitempty"`
}
func (u *User) BeforeCreate(tx *gorm.DB) error {

View File

@ -2,6 +2,7 @@ package handler
import (
"context"
"eslogad-be/internal/appcontext"
"eslogad-be/internal/contract"
@ -71,12 +72,9 @@ func (h *DispositionRouteHandler) Get(c *gin.Context) {
}
func (h *DispositionRouteHandler) ListByFromDept(c *gin.Context) {
fromID, err := uuid.Parse(c.Param("from_department_id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid from_department_id", Code: 400})
return
}
resp, err := h.svc.ListByFromDept(c.Request.Context(), fromID)
appCtx := appcontext.FromGinContext(c.Request.Context())
resp, err := h.svc.ListByFromDept(c.Request.Context(), appCtx.DepartmentID)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return

View File

@ -2,6 +2,7 @@ package handler
import (
"context"
"eslogad-be/internal/appcontext"
"net/http"
"strconv"
@ -19,7 +20,7 @@ type LetterService interface {
SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error
CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error)
ListDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*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)
@ -112,11 +113,13 @@ func (h *LetterHandler) DeleteIncomingLetter(c *gin.Context) {
}
func (h *LetterHandler) CreateDispositions(c *gin.Context) {
appCtx := appcontext.FromGinContext(c.Request.Context())
var req contract.CreateLetterDispositionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
req.FromDepartment = appCtx.DepartmentID
resp, err := h.svc.CreateDispositions(c.Request.Context(), &req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
@ -125,13 +128,13 @@ func (h *LetterHandler) CreateDispositions(c *gin.Context) {
c.JSON(201, contract.BuildSuccessResponse(resp))
}
func (h *LetterHandler) ListDispositionsByLetter(c *gin.Context) {
func (h *LetterHandler) GetEnhancedDispositionsByLetter(c *gin.Context) {
letterID, err := uuid.Parse(c.Param("letter_id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid letter_id", Code: 400})
return
}
resp, err := h.svc.ListDispositionsByLetter(c.Request.Context(), letterID)
resp, err := h.svc.GetEnhancedDispositionsByLetter(c.Request.Context(), letterID)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return

View File

@ -24,7 +24,7 @@ type MasterService interface {
CreateInstitution(ctx context.Context, req *contract.CreateInstitutionRequest) (*contract.InstitutionResponse, error)
UpdateInstitution(ctx context.Context, id uuid.UUID, req *contract.UpdateInstitutionRequest) (*contract.InstitutionResponse, error)
DeleteInstitution(ctx context.Context, id uuid.UUID) error
ListInstitutions(ctx context.Context) (*contract.ListInstitutionsResponse, error)
ListInstitutions(ctx context.Context, req *contract.ListInstitutionsRequest) (*contract.ListInstitutionsResponse, error)
CreateDispositionAction(ctx context.Context, req *contract.CreateDispositionActionRequest) (*contract.DispositionActionResponse, error)
UpdateDispositionAction(ctx context.Context, id uuid.UUID, req *contract.UpdateDispositionActionRequest) (*contract.DispositionActionResponse, error)
@ -190,7 +190,13 @@ func (h *MasterHandler) DeleteInstitution(c *gin.Context) {
}
func (h *MasterHandler) ListInstitutions(c *gin.Context) {
resp, err := h.svc.ListInstitutions(c.Request.Context())
var req contract.ListInstitutionsRequest
if search := c.Query("search"); search != "" {
req.Search = &search
}
resp, err := h.svc.ListInstitutions(c.Request.Context(), &req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return

View File

@ -0,0 +1,190 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"eslogad-be/internal/contract"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// MockMasterService is a mock implementation of MasterService
type MockMasterService struct {
mock.Mock
}
func (m *MockMasterService) CreateLabel(ctx context.Context, req *contract.CreateLabelRequest) (*contract.LabelResponse, error) {
args := m.Called(ctx, req)
return args.Get(0).(*contract.LabelResponse), args.Error(1)
}
func (m *MockMasterService) UpdateLabel(ctx context.Context, id uuid.UUID, req *contract.UpdateLabelRequest) (*contract.LabelResponse, error) {
args := m.Called(ctx, id, req)
return args.Get(0).(*contract.LabelResponse), args.Error(1)
}
func (m *MockMasterService) DeleteLabel(ctx context.Context, id uuid.UUID) error {
args := m.Called(ctx, id)
return args.Error(0)
}
func (m *MockMasterService) ListLabels(ctx context.Context) (*contract.ListLabelsResponse, error) {
args := m.Called(ctx)
return args.Get(0).(*contract.ListLabelsResponse), args.Error(1)
}
func (m *MockMasterService) CreatePriority(ctx context.Context, req *contract.CreatePriorityRequest) (*contract.PriorityResponse, error) {
args := m.Called(ctx, req)
return args.Get(0).(*contract.PriorityResponse), args.Error(1)
}
func (m *MockMasterService) UpdatePriority(ctx context.Context, id uuid.UUID, req *contract.UpdatePriorityRequest) (*contract.PriorityResponse, error) {
args := m.Called(ctx, id, req)
return args.Get(0).(*contract.PriorityResponse), args.Error(1)
}
func (m *MockMasterService) DeletePriority(ctx context.Context, id uuid.UUID) error {
args := m.Called(ctx, id)
return args.Error(0)
}
func (m *MockMasterService) ListPriorities(ctx context.Context) (*contract.ListPrioritiesResponse, error) {
args := m.Called(ctx)
return args.Get(0).(*contract.ListPrioritiesResponse), args.Error(1)
}
func (m *MockMasterService) CreateInstitution(ctx context.Context, req *contract.CreateInstitutionRequest) (*contract.InstitutionResponse, error) {
args := m.Called(ctx, req)
return args.Get(0).(*contract.InstitutionResponse), args.Error(1)
}
func (m *MockMasterService) UpdateInstitution(ctx context.Context, id uuid.UUID, req *contract.UpdateInstitutionRequest) (*contract.InstitutionResponse, error) {
args := m.Called(ctx, id, req)
return args.Get(0).(*contract.InstitutionResponse), args.Error(1)
}
func (m *MockMasterService) DeleteInstitution(ctx context.Context, id uuid.UUID) error {
args := m.Called(ctx, id)
return args.Error(0)
}
func (m *MockMasterService) ListInstitutions(ctx context.Context, req *contract.ListInstitutionsRequest) (*contract.ListInstitutionsResponse, error) {
args := m.Called(ctx, req)
return args.Get(0).(*contract.ListInstitutionsResponse), args.Error(1)
}
func (m *MockMasterService) CreateDispositionAction(ctx context.Context, req *contract.CreateDispositionActionRequest) (*contract.DispositionActionResponse, error) {
args := m.Called(ctx, req)
return args.Get(0).(*contract.DispositionActionResponse), args.Error(1)
}
func (m *MockMasterService) UpdateDispositionAction(ctx context.Context, id uuid.UUID, req *contract.UpdateDispositionActionRequest) (*contract.DispositionActionResponse, error) {
args := m.Called(ctx, id, req)
return args.Get(0).(*contract.DispositionActionResponse), args.Error(1)
}
func (m *MockMasterService) DeleteDispositionAction(ctx context.Context, id uuid.UUID) error {
args := m.Called(ctx, id)
return args.Error(0)
}
func (m *MockMasterService) ListDispositionActions(ctx context.Context) (*contract.ListDispositionActionsResponse, error) {
args := m.Called(ctx)
return args.Get(0).(*contract.ListDispositionActionsResponse), args.Error(1)
}
func TestMasterHandler_ListInstitutions_WithSearch(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
mockService := new(MockMasterService)
handler := NewMasterHandler(mockService)
// Test data
searchTerm := "university"
expectedResponse := &contract.ListInstitutionsResponse{
Institutions: []contract.InstitutionResponse{
{
ID: "123",
Name: "Test University",
Type: "university",
},
},
}
// Setup mock expectations
mockService.On("ListInstitutions", mock.Anything, &contract.ListInstitutionsRequest{
Search: &searchTerm,
}).Return(expectedResponse, nil)
// Create request
req, _ := http.NewRequest("GET", "/institutions?search="+searchTerm, nil)
w := httptest.NewRecorder()
// Create gin context
c, _ := gin.CreateTestContext(w)
c.Request = req
// Execute
handler.ListInstitutions(c)
// Assertions
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
// Verify mock was called correctly
mockService.AssertExpectations(t)
}
func TestMasterHandler_ListInstitutions_WithoutSearch(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
mockService := new(MockMasterService)
handler := NewMasterHandler(mockService)
// Test data
expectedResponse := &contract.ListInstitutionsResponse{
Institutions: []contract.InstitutionResponse{
{
ID: "123",
Name: "Test Institution",
Type: "company",
},
},
}
// Setup mock expectations
mockService.On("ListInstitutions", mock.Anything, &contract.ListInstitutionsRequest{
Search: nil,
}).Return(expectedResponse, nil)
// Create request
req, _ := http.NewRequest("GET", "/institutions", nil)
w := httptest.NewRecorder()
// Create gin context
c, _ := gin.CreateTestContext(w)
c.Request = req
// Execute
handler.ListInstitutions(c)
// Assertions
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
// Verify mock was called correctly
mockService.AssertExpectations(t)
}

View File

@ -285,12 +285,48 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
}
func (h *UserHandler) ListTitles(c *gin.Context) {
resp, err := h.userService.ListTitles(c.Request.Context())
titles, err := h.userService.ListTitles(c.Request.Context())
if err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::ListTitles -> Failed to get titles from service")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
logger.FromContext(c).Infof("UserHandler::ListTitles -> Successfully retrieved titles = %+v", titles)
c.JSON(http.StatusOK, titles)
}
func (h *UserHandler) GetActiveUsersForMention(c *gin.Context) {
search := c.Query("search")
limitStr := c.DefaultQuery("limit", "50")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 50
}
if limit > 100 {
limit = 100
}
var searchPtr *string
if search != "" {
searchPtr = &search
}
users, err := h.userService.GetActiveUsersForMention(c.Request.Context(), searchPtr, limit)
if err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::GetActiveUsersForMention -> Failed to get active users from service")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
response := contract.MentionUsersResponse{
Users: users,
Count: len(users),
}
logger.FromContext(c).Infof("UserHandler::GetActiveUsersForMention -> Successfully retrieved %d active users", len(users))
c.JSON(http.StatusOK, response)
}
func (h *UserHandler) sendErrorResponse(c *gin.Context, message string, statusCode int) {

View File

@ -20,4 +20,7 @@ type UserService interface {
UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error)
ListTitles(ctx context.Context) (*contract.ListTitlesResponse, error)
// Get active users for mention purposes
GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error)
}

View File

@ -41,6 +41,12 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
}
setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String())
if len(userResponse.DepartmentResponse) > 0 {
departmentID := userResponse.DepartmentResponse[0].ID.String()
setKeyInContext(c, appcontext.DepartmentIDKey, departmentID)
} else {
setKeyInContext(c, appcontext.DepartmentIDKey, "")
}
if roles, perms, err := m.authService.ExtractAccess(token); err == nil {
c.Set("user_roles", roles)

View File

@ -13,7 +13,7 @@ func PopulateContext() gin.HandlerFunc {
setKeyInContext(c, appcontext.AppVersionKey, getAppVersion(c))
setKeyInContext(c, appcontext.AppTypeKey, getAppType(c))
setKeyInContext(c, appcontext.OrganizationIDKey, getOrganizationID(c))
setKeyInContext(c, appcontext.OutletIDKey, getOutletID(c))
setKeyInContext(c, appcontext.DepartmentIDKey, getDepartmentID(c))
setKeyInContext(c, appcontext.DeviceOSKey, getDeviceOS(c))
setKeyInContext(c, appcontext.PlatformKey, getDevicePlatform(c))
setKeyInContext(c, appcontext.UserLocaleKey, getUserLocale(c))
@ -37,8 +37,8 @@ func getOrganizationID(c *gin.Context) string {
return c.GetHeader(constants.OrganizationID)
}
func getOutletID(c *gin.Context) string {
return c.GetHeader(constants.OutletID)
func getDepartmentID(c *gin.Context) string {
return c.GetHeader(constants.DepartmentID)
}
func getDeviceOS(c *gin.Context) string {

View File

@ -15,25 +15,26 @@ import (
)
type LetterProcessorImpl struct {
letterRepo *repository.LetterIncomingRepository
attachRepo *repository.LetterIncomingAttachmentRepository
txManager *repository.TxManager
activity *ActivityLogProcessorImpl
// new repos for dispositions
dispositionRepo *repository.LetterDispositionRepository
letterRepo *repository.LetterIncomingRepository
attachRepo *repository.LetterIncomingAttachmentRepository
txManager *repository.TxManager
activity *ActivityLogProcessorImpl
dispositionRepo *repository.LetterIncomingDispositionRepository
dispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository
dispositionActionSelRepo *repository.LetterDispositionActionSelectionRepository
dispositionNoteRepo *repository.DispositionNoteRepository
// discussion repo
discussionRepo *repository.LetterDiscussionRepository
// settings and recipients
settingRepo *repository.AppSettingRepository
recipientRepo *repository.LetterIncomingRecipientRepository
departmentRepo *repository.DepartmentRepository
userDeptRepo *repository.UserDepartmentRepository
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.LetterDispositionRepository, dispSelRepo *repository.LetterDispositionActionSelectionRepository, noteRepo *repository.DispositionNoteRepository, discussionRepo *repository.LetterDiscussionRepository, settingRepo *repository.AppSettingRepository, recipientRepo *repository.LetterIncomingRecipientRepository, departmentRepo *repository.DepartmentRepository, userDeptRepo *repository.UserDepartmentRepository) *LetterProcessorImpl {
return &LetterProcessorImpl{letterRepo: letterRepo, attachRepo: attachRepo, txManager: txManager, activity: activity, dispositionRepo: dispRepo, dispositionActionSelRepo: dispSelRepo, dispositionNoteRepo: noteRepo, discussionRepo: discussionRepo, settingRepo: settingRepo, recipientRepo: recipientRepo, departmentRepo: departmentRepo, userDeptRepo: userDeptRepo}
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) {
@ -85,7 +86,6 @@ func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *con
}
}
// resolve department codes to ids using repository
depIDs := make([]uuid.UUID, 0, len(defaultDeptCodes))
for _, code := range defaultDeptCodes {
dep, err := p.departmentRepo.GetByCode(txCtx, code)
@ -94,20 +94,19 @@ func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *con
}
depIDs = append(depIDs, dep.ID)
}
// query user memberships for all departments at once
userMemberships, _ := p.userDeptRepo.ListActiveByDepartmentIDs(txCtx, depIDs)
// build recipients: one department recipient per department + one user recipient per membership
recipients := make([]entities.LetterIncomingRecipient, 0, len(depIDs)+len(userMemberships))
// department recipients
for _, depID := range depIDs {
id := depID
recipients = append(recipients, entities.LetterIncomingRecipient{LetterID: entity.ID, RecipientDepartmentID: &id, Status: entities.RecipientStatusNew})
}
// user recipients
var recipients []entities.LetterIncomingRecipient
mapsUsers := map[string]bool{}
for _, row := range userMemberships {
uid := row.UserID
recipients = append(recipients, entities.LetterIncomingRecipient{LetterID: entity.ID, RecipientUserID: &uid, Status: entities.RecipientStatusNew})
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
@ -141,9 +140,26 @@ func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *con
}
savedAttachments, _ := p.attachRepo.ListByLetter(txCtx, entity.ID)
result = transformer.LetterEntityToContract(entity, savedAttachments)
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
}
@ -156,7 +172,19 @@ func (p *LetterProcessorImpl) GetIncomingLetterByID(ctx context.Context, id uuid
return nil, err
}
atts, _ := p.attachRepo.ListByLetter(ctx, id)
return transformer.LetterEntityToContract(entity, atts), nil
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) {
@ -175,7 +203,19 @@ func (p *LetterProcessorImpl) ListIncomingLetters(ctx context.Context, req *cont
respList := make([]contract.IncomingLetterResponse, 0, len(list))
for _, e := range list {
atts, _ := p.attachRepo.ListByLetter(ctx, e.ID)
resp := transformer.LetterEntityToContract(&e, atts)
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
@ -225,7 +265,19 @@ func (p *LetterProcessorImpl) UpdateIncomingLetter(ctx context.Context, id uuid.
}
}
atts, _ := p.attachRepo.ListByLetter(txCtx, id)
out = transformer.LetterEntityToContract(entity, atts)
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 {
@ -254,48 +306,53 @@ func (p *LetterProcessorImpl) CreateDispositions(ctx context.Context, req *contr
var out *contract.ListDispositionsResponse
err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
userID := appcontext.FromGinContext(txCtx).UserID
created := make([]entities.LetterDisposition, 0, len(req.ToDepartmentIDs))
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 {
disp := entities.LetterDisposition{
LetterID: req.LetterID,
FromDepartmentID: nil,
ToDepartmentID: &toDept,
Notes: req.Notes,
Status: entities.DispositionPending,
CreatedBy: userID,
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.dispositionRepo.Create(txCtx, &disp); err != nil {
if err := p.dispositionActionSelRepo.CreateBulk(txCtx, selections); err != nil {
return err
}
created = append(created, disp)
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"
for _, d := range created {
ctxMap := map[string]interface{}{"to_department_id": d.ToDepartmentID}
if err := p.activity.Log(txCtx, req.LetterID, action, &userID, nil, nil, &d.ID, nil, nil, ctxMap); err != nil {
return err
}
}
}
}
out = &contract.ListDispositionsResponse{Dispositions: transformer.DispositionsToContract(created)}
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 {
@ -312,6 +369,64 @@ func (p *LetterProcessorImpl) ListDispositionsByLetter(ctx context.Context, lett
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 {
@ -320,7 +435,7 @@ func (p *LetterProcessorImpl) CreateDiscussion(ctx context.Context, letterID uui
if req.Mentions != nil {
mentions = entities.JSONB(req.Mentions)
}
disc := &entities.LetterDiscussion{LetterID: letterID, ParentID: req.ParentID, UserID: userID, Message: req.Message, Mentions: 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
}

View File

@ -112,9 +112,11 @@ func (p *UserProcessorImpl) GetUserByID(ctx context.Context, id uuid.UUID) (*con
}
resp := transformer.EntityToContract(user)
if resp != nil {
// Roles are loaded separately since they're not preloaded
if roles, err := p.userRepo.GetRolesByUserID(ctx, resp.ID); err == nil {
resp.Roles = transformer.RolesToContract(roles)
}
// Departments are now preloaded, so they're already in the response
}
return resp, nil
}
@ -125,6 +127,7 @@ func (p *UserProcessorImpl) GetUserByEmail(ctx context.Context, email string) (*
return nil, fmt.Errorf("user not found: %w", err)
}
// Departments are now preloaded, so they're already in the response
return transformer.EntityToContract(user), nil
}
@ -149,6 +152,7 @@ func (p *UserProcessorImpl) ListUsersWithFilters(ctx context.Context, req *contr
for i := range responses {
userIDs = append(userIDs, responses[i].ID)
}
// Roles are loaded separately since they're not preloaded
rolesMap, err := p.userRepo.GetRolesByUserIDs(ctx, userIDs)
if err == nil {
for i := range responses {
@ -157,6 +161,7 @@ func (p *UserProcessorImpl) ListUsersWithFilters(ctx context.Context, req *contr
}
}
}
// Departments are now preloaded, so they're already in the responses
return responses, int(totalCount), nil
}
@ -272,3 +277,38 @@ func (p *UserProcessorImpl) UpdateUserProfile(ctx context.Context, userID uuid.U
}
return transformer.ProfileEntityToContract(entity), nil
}
// GetActiveUsersForMention retrieves active users for mention purposes with optional username search
func (p *UserProcessorImpl) GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) {
if limit <= 0 {
limit = 50 // Default limit for mention suggestions
}
if limit > 100 {
limit = 100 // Max limit for mention suggestions
}
// Set isActive to true to only get active users
isActive := true
users, _, err := p.userRepo.ListWithFilters(ctx, search, nil, &isActive, limit, 0)
if err != nil {
return nil, fmt.Errorf("failed to get active users: %w", err)
}
responses := transformer.EntitiesToContracts(users)
userIDs := make([]uuid.UUID, 0, len(responses))
for i := range responses {
userIDs = append(userIDs, responses[i].ID)
}
// Load roles for the users
rolesMap, err := p.userRepo.GetRolesByUserIDs(ctx, userIDs)
if err == nil {
for i := range responses {
if roles, ok := rolesMap[responses[i].ID]; ok {
responses[i].Roles = transformer.RolesToContract(roles)
}
}
}
return responses, nil
}

View File

@ -0,0 +1,250 @@
package processor
import (
"context"
"testing"
"eslogad-be/internal/contract"
"eslogad-be/internal/entities"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// MockUserRepository is a mock implementation of UserRepository
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) Create(ctx context.Context, user *entities.User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
func (m *MockUserRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.User, error) {
args := m.Called(ctx, id)
return args.Get(0).(*entities.User), args.Error(1)
}
func (m *MockUserRepository) GetByEmail(ctx context.Context, email string) (*entities.User, error) {
args := m.Called(ctx, email)
return args.Get(0).(*entities.User), args.Error(1)
}
func (m *MockUserRepository) GetByRole(ctx context.Context, role entities.UserRole) ([]*entities.User, error) {
args := m.Called(ctx, role)
return args.Get(0).([]*entities.User), args.Error(1)
}
func (m *MockUserRepository) GetActiveUsers(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error) {
args := m.Called(ctx, organizationID)
return args.Get(0).([]*entities.User), args.Error(1)
}
func (m *MockUserRepository) Update(ctx context.Context, user *entities.User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
func (m *MockUserRepository) Delete(ctx context.Context, id uuid.UUID) error {
args := m.Called(ctx, id)
return args.Error(0)
}
func (m *MockUserRepository) UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error {
args := m.Called(ctx, id, passwordHash)
return args.Error(0)
}
func (m *MockUserRepository) UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error {
args := m.Called(ctx, id, isActive)
return args.Error(0)
}
func (m *MockUserRepository) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.User, int64, error) {
args := m.Called(ctx, filters, limit, offset)
return args.Get(0).([]*entities.User), args.Get(1).(int64), args.Error(2)
}
func (m *MockUserRepository) Count(ctx context.Context, filters map[string]interface{}) (int64, error) {
args := m.Called(ctx, filters)
return args.Get(0).(int64), args.Error(1)
}
func (m *MockUserRepository) GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) {
args := m.Called(ctx, userID)
return args.Get(0).([]entities.Role), args.Error(1)
}
func (m *MockUserRepository) GetPermissionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Permission, error) {
args := m.Called(ctx, userID)
return args.Get(0).([]entities.Permission), args.Error(1)
}
func (m *MockUserRepository) GetDepartmentsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Department, error) {
args := m.Called(ctx, userID)
return args.Get(0).([]entities.Department), args.Error(1)
}
func (m *MockUserRepository) GetRolesByUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]entities.Role, error) {
args := m.Called(ctx, userIDs)
return args.Get(0).(map[uuid.UUID][]entities.Role), args.Error(1)
}
func (m *MockUserRepository) ListWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]*entities.User, int64, error) {
args := m.Called(ctx, search, roleCode, isActive, limit, offset)
return args.Get(0).([]*entities.User), args.Get(1).(int64), args.Error(2)
}
// MockUserProfileRepository is a mock implementation of UserProfileRepository
type MockUserProfileRepository struct {
mock.Mock
}
func (m *MockUserProfileRepository) GetByUserID(ctx context.Context, userID uuid.UUID) (*entities.UserProfile, error) {
args := m.Called(ctx, userID)
return args.Get(0).(*entities.UserProfile), args.Error(1)
}
func (m *MockUserProfileRepository) Create(ctx context.Context, profile *entities.UserProfile) error {
args := m.Called(ctx, profile)
return args.Error(0)
}
func (m *MockUserProfileRepository) Upsert(ctx context.Context, profile *entities.UserProfile) error {
args := m.Called(ctx, profile)
return args.Error(0)
}
func (m *MockUserProfileRepository) Update(ctx context.Context, profile *entities.UserProfile) error {
args := m.Called(ctx, profile)
return args.Error(0)
}
func TestGetActiveUsersForMention(t *testing.T) {
tests := []struct {
name string
search *string
limit int
mockUsers []*entities.User
mockRoles map[uuid.UUID][]entities.Role
expectedCount int
expectedError bool
setupMocks func(*MockUserRepository, *MockUserProfileRepository)
}{
{
name: "success with search",
search: stringPtr("john"),
limit: 10,
mockUsers: []*entities.User{
{
ID: uuid.New(),
Name: "John Doe",
Email: "john@example.com",
IsActive: true,
},
},
expectedCount: 1,
expectedError: false,
setupMocks: func(mockRepo *MockUserRepository, mockProfileRepo *MockUserProfileRepository) {
mockRepo.On("ListWithFilters", mock.Anything, stringPtr("john"), (*string)(nil), boolPtr(true), 10, 0).
Return([]*entities.User{
{
ID: uuid.New(),
Name: "John Doe",
Email: "john@example.com",
IsActive: true,
},
}, int64(1), nil)
mockRepo.On("GetRolesByUserIDs", mock.Anything, mock.AnythingOfType("[]uuid.UUID")).
Return(map[uuid.UUID][]entities.Role{}, nil)
},
},
{
name: "success without search",
search: nil,
limit: 50,
mockUsers: []*entities.User{
{
ID: uuid.New(),
Name: "Jane Doe",
Email: "jane@example.com",
IsActive: true,
},
},
expectedCount: 1,
expectedError: false,
setupMocks: func(mockRepo *MockUserRepository, mockProfileRepo *MockUserProfileRepository) {
mockRepo.On("ListWithFilters", mock.Anything, (*string)(nil), (*string)(nil), boolPtr(true), 50, 0).
Return([]*entities.User{
{
ID: uuid.New(),
Name: "Jane Doe",
Email: "jane@example.com",
IsActive: true,
},
}, int64(1), nil)
mockRepo.On("GetRolesByUserIDs", mock.Anything, mock.AnythingOfType("[]uuid.UUID")).
Return(map[uuid.UUID][]entities.Role{}, nil)
},
},
{
name: "limit validation - too high",
search: nil,
limit: 150,
mockUsers: []*entities.User{},
expectedCount: 0,
expectedError: false,
setupMocks: func(mockRepo *MockUserRepository, mockProfileRepo *MockUserProfileRepository) {
mockRepo.On("ListWithFilters", mock.Anything, (*string)(nil), (*string)(nil), boolPtr(true), 100, 0).
Return([]*entities.User{}, int64(0), nil)
mockRepo.On("GetRolesByUserIDs", mock.Anything, mock.AnythingOfType("[]uuid.UUID")).
Return(map[uuid.UUID][]entities.Role{}, nil)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mocks
mockRepo := &MockUserRepository{}
mockProfileRepo := &MockUserProfileRepository{}
// Setup mocks
if tt.setupMocks != nil {
tt.setupMocks(mockRepo, mockProfileRepo)
}
// Create processor
processor := NewUserProcessor(mockRepo, mockProfileRepo)
// Call method
result, err := processor.GetActiveUsersForMention(context.Background(), tt.search, tt.limit)
// Assertions
if tt.expectedError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Len(t, result, tt.expectedCount)
}
// Verify mocks
mockRepo.AssertExpectations(t)
mockProfileRepo.AssertExpectations(t)
})
}
}
// Helper functions
func stringPtr(s string) *string {
return &s
}
func boolPtr(b bool) *bool {
return &b
}

View File

@ -26,7 +26,10 @@ func (r *DispositionRouteRepository) Update(ctx context.Context, e *entities.Dis
func (r *DispositionRouteRepository) Get(ctx context.Context, id uuid.UUID) (*entities.DispositionRoute, error) {
db := DBFromContext(ctx, r.db)
var e entities.DispositionRoute
if err := db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil {
if err := db.WithContext(ctx).
Preload("FromDepartment").
Preload("ToDepartment").
First(&e, "id = ?", id).Error; err != nil {
return nil, err
}
return &e, nil
@ -34,11 +37,15 @@ func (r *DispositionRouteRepository) Get(ctx context.Context, id uuid.UUID) (*en
func (r *DispositionRouteRepository) ListByFromDept(ctx context.Context, fromDept uuid.UUID) ([]entities.DispositionRoute, error) {
db := DBFromContext(ctx, r.db)
var list []entities.DispositionRoute
if err := db.WithContext(ctx).Where("from_department_id = ?", fromDept).Order("to_department_id").Find(&list).Error; err != nil {
if err := db.WithContext(ctx).Where("from_department_id = ?", fromDept).
Preload("FromDepartment").
Preload("ToDepartment").
Order("to_department_id").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (r *DispositionRouteRepository) SetActive(ctx context.Context, id uuid.UUID, isActive bool) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Model(&entities.DispositionRoute{}).Where("id = ?", id).Update("is_active", isActive).Error

View File

@ -104,19 +104,56 @@ func (r *LetterIncomingActivityLogRepository) ListByLetter(ctx context.Context,
return list, nil
}
type LetterDispositionRepository struct{ db *gorm.DB }
type LetterIncomingDispositionRepository struct{ db *gorm.DB }
func NewLetterDispositionRepository(db *gorm.DB) *LetterDispositionRepository {
return &LetterDispositionRepository{db: db}
func NewLetterIncomingDispositionRepository(db *gorm.DB) *LetterIncomingDispositionRepository {
return &LetterIncomingDispositionRepository{db: db}
}
func (r *LetterDispositionRepository) Create(ctx context.Context, e *entities.LetterDisposition) error {
func (r *LetterIncomingDispositionRepository) Create(ctx context.Context, e *entities.LetterIncomingDisposition) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(e).Error
}
func (r *LetterDispositionRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterDisposition, error) {
func (r *LetterIncomingDispositionRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingDisposition, error) {
db := DBFromContext(ctx, r.db)
var list []entities.LetterDisposition
if err := db.WithContext(ctx).Where("letter_id = ?", letterID).Order("created_at ASC").Find(&list).Error; err != nil {
var list []entities.LetterIncomingDisposition
if err := db.WithContext(ctx).
Where("letter_id = ?", letterID).
Preload("Department").
Preload("Departments.Department").
Preload("ActionSelections.Action").
Preload("DispositionNotes.User").
Order("created_at ASC").
Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
type LetterIncomingDispositionDepartmentRepository struct{ db *gorm.DB }
func NewLetterIncomingDispositionDepartmentRepository(db *gorm.DB) *LetterIncomingDispositionDepartmentRepository {
return &LetterIncomingDispositionDepartmentRepository{db: db}
}
func (r *LetterIncomingDispositionDepartmentRepository) CreateBulk(ctx context.Context, list []entities.LetterIncomingDispositionDepartment) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(&list).Error
}
func (r *LetterIncomingDispositionDepartmentRepository) ListByDisposition(ctx context.Context, dispositionID uuid.UUID) ([]entities.LetterIncomingDispositionDepartment, error) {
db := DBFromContext(ctx, r.db)
var list []entities.LetterIncomingDispositionDepartment
if err := db.WithContext(ctx).Where("letter_incoming_disposition_id = ?", dispositionID).Order("created_at ASC").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (r *LetterIncomingDispositionDepartmentRepository) ListByDispositions(ctx context.Context, dispositionIDs []uuid.UUID) ([]entities.LetterIncomingDispositionDepartment, error) {
db := DBFromContext(ctx, r.db)
var list []entities.LetterIncomingDispositionDepartment
if len(dispositionIDs) == 0 {
return list, nil
}
if err := db.WithContext(ctx).Where("letter_incoming_disposition_id IN ?", dispositionIDs).Order("letter_incoming_disposition_id, created_at ASC").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
@ -132,6 +169,27 @@ func (r *DispositionNoteRepository) Create(ctx context.Context, e *entities.Disp
return db.WithContext(ctx).Create(e).Error
}
func (r *DispositionNoteRepository) ListByDisposition(ctx context.Context, dispositionID uuid.UUID) ([]entities.DispositionNote, error) {
db := DBFromContext(ctx, r.db)
var list []entities.DispositionNote
if err := db.WithContext(ctx).Where("disposition_id = ?", dispositionID).Order("created_at ASC").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (r *DispositionNoteRepository) ListByDispositions(ctx context.Context, dispositionIDs []uuid.UUID) ([]entities.DispositionNote, error) {
db := DBFromContext(ctx, r.db)
var list []entities.DispositionNote
if len(dispositionIDs) == 0 {
return list, nil
}
if err := db.WithContext(ctx).Where("disposition_id IN ?", dispositionIDs).Order("disposition_id, created_at ASC").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
type LetterDispositionActionSelectionRepository struct{ db *gorm.DB }
func NewLetterDispositionActionSelectionRepository(db *gorm.DB) *LetterDispositionActionSelectionRepository {
@ -150,6 +208,18 @@ func (r *LetterDispositionActionSelectionRepository) ListByDisposition(ctx conte
return list, nil
}
func (r *LetterDispositionActionSelectionRepository) ListByDispositions(ctx context.Context, dispositionIDs []uuid.UUID) ([]entities.LetterDispositionActionSelection, error) {
db := DBFromContext(ctx, r.db)
var list []entities.LetterDispositionActionSelection
if len(dispositionIDs) == 0 {
return list, nil
}
if err := db.WithContext(ctx).Where("disposition_id IN ?", dispositionIDs).Order("disposition_id, created_at ASC").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
type LetterDiscussionRepository struct{ db *gorm.DB }
func NewLetterDiscussionRepository(db *gorm.DB) *LetterDiscussionRepository {
@ -179,6 +249,35 @@ func (r *LetterDiscussionRepository) Update(ctx context.Context, e *entities.Let
Updates(map[string]interface{}{"message": e.Message, "mentions": e.Mentions, "edited_at": e.EditedAt}).Error
}
func (r *LetterDiscussionRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterDiscussion, error) {
db := DBFromContext(ctx, r.db)
var list []entities.LetterDiscussion
if err := db.WithContext(ctx).
Where("letter_id = ?", letterID).
Preload("User.Profile").
Order("created_at ASC").
Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (r *LetterDiscussionRepository) GetUsersByIDs(ctx context.Context, userIDs []uuid.UUID) ([]entities.User, error) {
if len(userIDs) == 0 {
return []entities.User{}, nil
}
db := DBFromContext(ctx, r.db)
var users []entities.User
if err := db.WithContext(ctx).
Where("id IN ?", userIDs).
Preload("Profile").
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
type AppSettingRepository struct{ db *gorm.DB }
func NewAppSettingRepository(db *gorm.DB) *AppSettingRepository { return &AppSettingRepository{db: db} }

View File

@ -78,6 +78,20 @@ func (r *InstitutionRepository) List(ctx context.Context) ([]entities.Institutio
err := r.db.WithContext(ctx).Order("name ASC").Find(&list).Error
return list, err
}
func (r *InstitutionRepository) ListWithSearch(ctx context.Context, search *string) ([]entities.Institution, error) {
var list []entities.Institution
q := r.db.WithContext(ctx).Model(&entities.Institution{})
if search != nil && *search != "" {
like := "%" + *search + "%"
q = q.Where("name ILIKE ? OR type ILIKE ? OR address ILIKE ? OR contact_person ILIKE ?", like, like, like, like)
}
err := q.Order("name ASC").Find(&list).Error
return list, err
}
func (r *InstitutionRepository) Get(ctx context.Context, id uuid.UUID) (*entities.Institution, error) {
var e entities.Institution
if err := r.db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil {
@ -113,6 +127,17 @@ func (r *DispositionActionRepository) Get(ctx context.Context, id uuid.UUID) (*e
return &e, nil
}
func (r *DispositionActionRepository) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]entities.DispositionAction, error) {
var actions []entities.DispositionAction
if len(ids) == 0 {
return actions, nil
}
if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&actions).Error; err != nil {
return nil, err
}
return actions, nil
}
type DepartmentRepository struct{ db *gorm.DB }
func NewDepartmentRepository(db *gorm.DB) *DepartmentRepository { return &DepartmentRepository{db: db} }
@ -125,3 +150,12 @@ func (r *DepartmentRepository) GetByCode(ctx context.Context, code string) (*ent
}
return &dep, nil
}
func (r *DepartmentRepository) Get(ctx context.Context, id uuid.UUID) (*entities.Department, error) {
db := DBFromContext(ctx, r.db)
var dep entities.Department
if err := db.WithContext(ctx).First(&dep, "id = ?", id).Error; err != nil {
return nil, err
}
return &dep, nil
}

View File

@ -25,7 +25,10 @@ func (r *UserRepositoryImpl) Create(ctx context.Context, user *entities.User) er
func (r *UserRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.User, error) {
var user entities.User
err := r.b.WithContext(ctx).Preload("Profile").First(&user, "id = ?", id).Error
err := r.b.WithContext(ctx).
Preload("Profile").
Preload("Departments").
First(&user, "id = ?", id).Error
if err != nil {
return nil, err
}
@ -34,7 +37,10 @@ func (r *UserRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entiti
func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*entities.User, error) {
var user entities.User
err := r.b.WithContext(ctx).Preload("Profile").Where("email = ?", email).First(&user).Error
err := r.b.WithContext(ctx).
Preload("Profile").
Preload("Departments").
Where("email = ?", email).First(&user).Error
if err != nil {
return nil, err
}
@ -43,7 +49,7 @@ func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*ent
func (r *UserRepositoryImpl) GetByRole(ctx context.Context, role entities.UserRole) ([]*entities.User, error) {
var users []*entities.User
err := r.b.WithContext(ctx).Preload("Profile").Where("role = ?", role).Find(&users).Error
err := r.b.WithContext(ctx).Preload("Profile").Preload("Departments").Where("role = ?", role).Find(&users).Error
return users, err
}
@ -52,6 +58,7 @@ func (r *UserRepositoryImpl) GetActiveUsers(ctx context.Context, organizationID
err := r.b.WithContext(ctx).
Where(" is_active = ?", organizationID, true).
Preload("Profile").
Preload("Departments").
Find(&users).Error
return users, err
}
@ -90,7 +97,7 @@ func (r *UserRepositoryImpl) List(ctx context.Context, filters map[string]interf
return nil, 0, err
}
err := query.Limit(limit).Offset(offset).Preload("Profile").Find(&users).Error
err := query.Limit(limit).Offset(offset).Preload("Profile").Preload("Departments").Find(&users).Error
return users, total, err
}
@ -141,19 +148,19 @@ func (r *UserRepositoryImpl) GetDepartmentsByUserID(ctx context.Context, userID
return departments, err
}
// GetRolesByUserIDs returns roles per user for a batch of user IDs
func (r *UserRepositoryImpl) GetRolesByUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]entities.Role, error) {
result := make(map[uuid.UUID][]entities.Role)
if len(userIDs) == 0 {
return result, nil
}
// fetch pairs user_id, role
type row struct {
UserID uuid.UUID
RoleID uuid.UUID
Name string
Code string
}
var rows []row
err := r.b.WithContext(ctx).
Table("user_role as ur").
@ -171,7 +178,6 @@ func (r *UserRepositoryImpl) GetRolesByUserIDs(ctx context.Context, userIDs []uu
return result, nil
}
// ListWithFilters supports name search and filtering by role code
func (r *UserRepositoryImpl) ListWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]*entities.User, int64, error) {
var users []*entities.User
var total int64
@ -194,7 +200,7 @@ func (r *UserRepositoryImpl) ListWithFilters(ctx context.Context, search *string
return nil, 0, err
}
if err := q.Select("users.*").Distinct("users.id").Limit(limit).Offset(offset).Preload("Profile").Find(&users).Error; err != nil {
if err := q.Select("users.*").Distinct("users.id").Limit(limit).Offset(offset).Preload("Profile").Preload("Departments").Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil

View File

@ -12,6 +12,7 @@ type UserHandler interface {
UpdateProfile(c *gin.Context)
ChangePassword(c *gin.Context)
ListTitles(c *gin.Context)
GetActiveUsersForMention(c *gin.Context)
}
type FileHandler interface {
@ -62,7 +63,8 @@ type LetterHandler interface {
DeleteIncomingLetter(c *gin.Context)
CreateDispositions(c *gin.Context)
ListDispositionsByLetter(c *gin.Context)
//ListDispositionsByLetter(c *gin.Context)
GetEnhancedDispositionsByLetter(c *gin.Context)
CreateDiscussion(c *gin.Context)
UpdateDiscussion(c *gin.Context)

View File

@ -82,6 +82,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
users.PUT("/profile", r.userHandler.UpdateProfile)
users.PUT(":id/password", r.userHandler.ChangePassword)
users.GET("/titles", r.userHandler.ListTitles)
users.GET("/mention", r.userHandler.GetActiveUsersForMention)
users.POST("/profile/avatar", r.fileHandler.UploadProfileAvatar)
}
@ -139,7 +140,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
lettersch.DELETE("/incoming/:id", r.letterHandler.DeleteIncomingLetter)
lettersch.POST("/dispositions/:letter_id", r.letterHandler.CreateDispositions)
lettersch.GET("/dispositions/:letter_id", r.letterHandler.ListDispositionsByLetter)
lettersch.GET("/dispositions/:letter_id", r.letterHandler.GetEnhancedDispositionsByLetter)
lettersch.POST("/discussions/:letter_id", r.letterHandler.CreateDiscussion)
lettersch.PUT("/discussions/:letter_id/:discussion_id", r.letterHandler.UpdateDiscussion)
@ -151,7 +152,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
droutes.POST("", r.dispRouteHandler.Create)
droutes.GET(":id", r.dispRouteHandler.Get)
droutes.PUT(":id", r.dispRouteHandler.Update)
droutes.GET("from/:from_department_id", r.dispRouteHandler.ListByFromDept)
droutes.GET("department", r.dispRouteHandler.ListByFromDept)
droutes.PUT(":id/active", r.dispRouteHandler.SetActive)
}
}

View File

@ -58,7 +58,7 @@ func (s *AuthServiceImpl) Login(ctx context.Context, req *contract.LoginRequest)
roles, _ := s.userProcessor.GetUserRoles(ctx, userResponse.ID)
permCodes, _ := s.userProcessor.GetUserPermissionCodes(ctx, userResponse.ID)
departments, _ := s.userProcessor.GetUserDepartments(ctx, userResponse.ID)
// Departments are now preloaded, so they're already in userResponse
token, expiresAt, err := s.generateToken(userResponse, roles, permCodes)
if err != nil {
@ -71,7 +71,7 @@ func (s *AuthServiceImpl) Login(ctx context.Context, req *contract.LoginRequest)
User: *userResponse,
Roles: roles,
Permissions: permCodes,
Departments: departments,
Departments: userResponse.DepartmentResponse,
}, nil
}
@ -90,6 +90,7 @@ func (s *AuthServiceImpl) ValidateToken(tokenString string) (*contract.UserRespo
return nil, fmt.Errorf("user account is deactivated")
}
// Departments are now preloaded, so they're already in the response
return userResponse, nil
}
@ -115,14 +116,14 @@ func (s *AuthServiceImpl) RefreshToken(ctx context.Context, tokenString string)
return nil, fmt.Errorf("failed to generate token: %w", err)
}
departments, _ := s.userProcessor.GetUserDepartments(ctx, userResponse.ID)
// Departments are now preloaded, so they're already in userResponse
return &contract.LoginResponse{
Token: newToken,
ExpiresAt: expiresAt,
User: *userResponse,
Roles: roles,
Permissions: permCodes,
Departments: departments,
Departments: userResponse.DepartmentResponse,
}, nil
}

View File

@ -16,7 +16,7 @@ type LetterProcessor interface {
SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error
CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error)
ListDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*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)
@ -50,8 +50,8 @@ func (s *LetterServiceImpl) CreateDispositions(ctx context.Context, req *contrac
return s.processor.CreateDispositions(ctx, req)
}
func (s *LetterServiceImpl) ListDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListDispositionsResponse, error) {
return s.processor.ListDispositionsByLetter(ctx, letterID)
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) {

View File

@ -140,8 +140,8 @@ func (s *MasterServiceImpl) UpdateInstitution(ctx context.Context, id uuid.UUID,
func (s *MasterServiceImpl) DeleteInstitution(ctx context.Context, id uuid.UUID) error {
return s.institutionRepo.Delete(ctx, id)
}
func (s *MasterServiceImpl) ListInstitutions(ctx context.Context) (*contract.ListInstitutionsResponse, error) {
list, err := s.institutionRepo.List(ctx)
func (s *MasterServiceImpl) ListInstitutions(ctx context.Context, req *contract.ListInstitutionsRequest) (*contract.ListInstitutionsResponse, error) {
list, err := s.institutionRepo.ListWithSearch(ctx, req.Search)
if err != nil {
return nil, err
}

View File

@ -26,4 +26,7 @@ type UserProcessor interface {
// New optimized listing
ListUsersWithFilters(ctx context.Context, req *contract.ListUsersRequest) ([]contract.UserResponse, int, error)
// Get active users for mention purposes
GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error)
}

View File

@ -96,3 +96,8 @@ func (s *UserServiceImpl) ListTitles(ctx context.Context) (*contract.ListTitlesR
}
return &contract.ListTitlesResponse{Titles: transformer.TitlesToContract(titles)}, nil
}
// GetActiveUsersForMention retrieves active users for mention purposes
func (s *UserServiceImpl) GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) {
return s.userProcessor.GetActiveUsersForMention(ctx, search, limit)
}

View File

@ -89,6 +89,10 @@ func DepartmentsToContract(positions []entities.Department) []contract.Departmen
return res
}
func DepartmentToContract(p entities.Department) contract.DepartmentResponse {
return contract.DepartmentResponse{ID: p.ID, Name: p.Name, Code: p.Code, Path: p.Path}
}
func ProfileEntityToContract(p *entities.UserProfile) *contract.UserProfileResponse {
if p == nil {
return nil
@ -241,7 +245,8 @@ func DispositionRoutesToContract(list []entities.DispositionRoute) []contract.Di
if e.AllowedActions != nil {
allowed = map[string]interface{}(e.AllowedActions)
}
out = append(out, contract.DispositionRouteResponse{
resp := contract.DispositionRouteResponse{
ID: e.ID,
FromDepartmentID: e.FromDepartmentID,
ToDepartmentID: e.ToDepartmentID,
@ -249,7 +254,26 @@ func DispositionRoutesToContract(list []entities.DispositionRoute) []contract.Di
AllowedActions: allowed,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
})
}
// Add department information if available
if e.FromDepartment.ID != uuid.Nil {
resp.FromDepartment = contract.DepartmentInfo{
ID: e.FromDepartment.ID,
Name: e.FromDepartment.Name,
Code: e.FromDepartment.Code,
}
}
if e.ToDepartment.ID != uuid.Nil {
resp.ToDepartment = contract.DepartmentInfo{
ID: e.ToDepartment.ID,
Name: e.ToDepartment.Name,
Code: e.ToDepartment.Code,
}
}
out = append(out, resp)
}
return out
}

View File

@ -3,24 +3,55 @@ package transformer
import (
"eslogad-be/internal/contract"
"eslogad-be/internal/entities"
"github.com/google/uuid"
)
func LetterEntityToContract(e *entities.LetterIncoming, attachments []entities.LetterIncomingAttachment) *contract.IncomingLetterResponse {
func LetterEntityToContract(e *entities.LetterIncoming, attachments []entities.LetterIncomingAttachment, refs ...interface{}) *contract.IncomingLetterResponse {
resp := &contract.IncomingLetterResponse{
ID: e.ID,
LetterNumber: e.LetterNumber,
ReferenceNumber: e.ReferenceNumber,
Subject: e.Subject,
Description: e.Description,
PriorityID: e.PriorityID,
SenderInstitutionID: e.SenderInstitutionID,
ReceivedDate: e.ReceivedDate,
DueDate: e.DueDate,
Status: string(e.Status),
CreatedBy: e.CreatedBy,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Attachments: make([]contract.IncomingLetterAttachmentResponse, 0, len(attachments)),
ID: e.ID,
LetterNumber: e.LetterNumber,
ReferenceNumber: e.ReferenceNumber,
Subject: e.Subject,
Description: e.Description,
ReceivedDate: e.ReceivedDate,
DueDate: e.DueDate,
Status: string(e.Status),
CreatedBy: e.CreatedBy,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Attachments: make([]contract.IncomingLetterAttachmentResponse, 0, len(attachments)),
}
// optional refs: allow passing already-fetched related objects
// expected ordering (if provided): *entities.Priority, *entities.Institution
for _, r := range refs {
switch v := r.(type) {
case *entities.Priority:
if v != nil {
resp.Priority = &contract.PriorityResponse{
ID: v.ID.String(),
Name: v.Name,
Level: v.Level,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
}
}
case *entities.Institution:
if v != nil {
resp.SenderInstitution = &contract.InstitutionResponse{
ID: v.ID.String(),
Name: v.Name,
Type: string(v.Type),
Address: v.Address,
ContactPerson: v.ContactPerson,
Phone: v.Phone,
Email: v.Email,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
}
}
}
}
for _, a := range attachments {
resp.Attachments = append(resp.Attachments, contract.IncomingLetterAttachmentResponse{
@ -34,19 +65,171 @@ func LetterEntityToContract(e *entities.LetterIncoming, attachments []entities.L
return resp
}
func DispositionsToContract(list []entities.LetterDisposition) []contract.DispositionResponse {
func DispositionsToContract(list []entities.LetterIncomingDisposition) []contract.DispositionResponse {
out := make([]contract.DispositionResponse, 0, len(list))
for _, d := range list {
out = append(out, contract.DispositionResponse{
out = append(out, DispoToContract(d))
}
return out
}
func DispoToContract(d entities.LetterIncomingDisposition) contract.DispositionResponse {
return contract.DispositionResponse{
ID: d.ID,
LetterID: d.LetterID,
DepartmentID: d.DepartmentID,
Notes: d.Notes,
ReadAt: d.ReadAt,
CreatedBy: d.CreatedBy,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
}
func EnhancedDispositionsToContract(list []entities.LetterIncomingDisposition) []contract.EnhancedDispositionResponse {
out := make([]contract.EnhancedDispositionResponse, 0, len(list))
for _, d := range list {
resp := contract.EnhancedDispositionResponse{
ID: d.ID,
LetterID: d.LetterID,
FromDepartmentID: d.FromDepartmentID,
ToDepartmentID: d.ToDepartmentID,
DepartmentID: d.DepartmentID,
Notes: d.Notes,
Status: string(d.Status),
ReadAt: d.ReadAt,
CreatedBy: d.CreatedBy,
CreatedAt: d.CreatedAt,
})
UpdatedAt: d.UpdatedAt,
Departments: []contract.DispositionDepartmentResponse{},
Actions: []contract.DispositionActionSelectionResponse{},
DispositionNotes: []contract.DispositionNoteResponse{},
}
out = append(out, resp)
}
return out
}
func DispositionDepartmentsToContract(list []entities.LetterIncomingDispositionDepartment) []contract.DispositionDepartmentResponse {
out := make([]contract.DispositionDepartmentResponse, 0, len(list))
for _, d := range list {
resp := contract.DispositionDepartmentResponse{
ID: d.ID,
DepartmentID: d.DepartmentID,
CreatedAt: d.CreatedAt,
}
out = append(out, resp)
}
return out
}
func DispositionDepartmentsWithDetailsToContract(list []entities.LetterIncomingDispositionDepartment) []contract.DispositionDepartmentResponse {
out := make([]contract.DispositionDepartmentResponse, 0, len(list))
for _, d := range list {
resp := contract.DispositionDepartmentResponse{
ID: d.ID,
DepartmentID: d.DepartmentID,
CreatedAt: d.CreatedAt,
}
// Include department details if preloaded
if d.Department != nil {
resp.Department = &contract.DepartmentResponse{
ID: d.Department.ID,
Name: d.Department.Name,
Code: d.Department.Code,
Path: d.Department.Path,
}
}
out = append(out, resp)
}
return out
}
func DispositionActionSelectionsToContract(list []entities.LetterDispositionActionSelection) []contract.DispositionActionSelectionResponse {
out := make([]contract.DispositionActionSelectionResponse, 0, len(list))
for _, d := range list {
resp := contract.DispositionActionSelectionResponse{
ID: d.ID,
ActionID: d.ActionID,
Action: nil, // Will be populated by processor
Note: d.Note,
CreatedBy: d.CreatedBy,
CreatedAt: d.CreatedAt,
}
out = append(out, resp)
}
return out
}
func DispositionActionSelectionsWithDetailsToContract(list []entities.LetterDispositionActionSelection) []contract.DispositionActionSelectionResponse {
out := make([]contract.DispositionActionSelectionResponse, 0, len(list))
for _, d := range list {
resp := contract.DispositionActionSelectionResponse{
ID: d.ID,
ActionID: d.ActionID,
Action: nil, // Will be populated by processor
Note: d.Note,
CreatedBy: d.CreatedBy,
CreatedAt: d.CreatedAt,
}
// Include action details if preloaded
if d.Action != nil {
resp.Action = &contract.DispositionActionResponse{
ID: d.Action.ID.String(),
Code: d.Action.Code,
Label: d.Action.Label,
Description: d.Action.Description,
RequiresNote: d.Action.RequiresNote,
GroupName: d.Action.GroupName,
SortOrder: d.Action.SortOrder,
IsActive: d.Action.IsActive,
CreatedAt: d.Action.CreatedAt,
UpdatedAt: d.Action.UpdatedAt,
}
}
out = append(out, resp)
}
return out
}
func DispositionNotesToContract(list []entities.DispositionNote) []contract.DispositionNoteResponse {
out := make([]contract.DispositionNoteResponse, 0, len(list))
for _, d := range list {
resp := contract.DispositionNoteResponse{
ID: d.ID,
UserID: d.UserID,
Note: d.Note,
CreatedAt: d.CreatedAt,
}
out = append(out, resp)
}
return out
}
func DispositionNotesWithDetailsToContract(list []entities.DispositionNote) []contract.DispositionNoteResponse {
out := make([]contract.DispositionNoteResponse, 0, len(list))
for _, d := range list {
resp := contract.DispositionNoteResponse{
ID: d.ID,
UserID: d.UserID,
Note: d.Note,
CreatedAt: d.CreatedAt,
}
// Include user details if preloaded
if d.User != nil {
resp.User = &contract.UserResponse{
ID: d.User.ID,
Name: d.User.Name,
Email: d.User.Email,
IsActive: d.User.IsActive,
CreatedAt: d.User.CreatedAt,
UpdatedAt: d.User.UpdatedAt,
}
}
out = append(out, resp)
}
return out
}
@ -68,3 +251,138 @@ func DiscussionEntityToContract(e *entities.LetterDiscussion) *contract.LetterDi
EditedAt: e.EditedAt,
}
}
func DiscussionsWithPreloadedDataToContract(list []entities.LetterDiscussion, mentionedUsers []entities.User) []contract.LetterDiscussionResponse {
// Create a map for efficient user lookup
userMap := make(map[uuid.UUID]entities.User)
for _, user := range mentionedUsers {
userMap[user.ID] = user
}
out := make([]contract.LetterDiscussionResponse, 0, len(list))
for _, d := range list {
resp := contract.LetterDiscussionResponse{
ID: d.ID,
LetterID: d.LetterID,
ParentID: d.ParentID,
UserID: d.UserID,
Message: d.Message,
Mentions: map[string]interface{}(d.Mentions),
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
EditedAt: d.EditedAt,
}
// Include user profile if preloaded
if d.User != nil {
resp.User = &contract.UserResponse{
ID: d.User.ID,
Name: d.User.Name,
Email: d.User.Email,
IsActive: d.User.IsActive,
CreatedAt: d.User.CreatedAt,
UpdatedAt: d.User.UpdatedAt,
}
// Include user profile if available
if d.User.Profile != nil {
resp.User.Profile = &contract.UserProfileResponse{
UserID: d.User.Profile.UserID,
FullName: d.User.Profile.FullName,
DisplayName: d.User.Profile.DisplayName,
Phone: d.User.Profile.Phone,
AvatarURL: d.User.Profile.AvatarURL,
JobTitle: d.User.Profile.JobTitle,
EmployeeNo: d.User.Profile.EmployeeNo,
Bio: d.User.Profile.Bio,
Timezone: d.User.Profile.Timezone,
Locale: d.User.Profile.Locale,
}
}
}
// Process mentions to get mentioned users with profiles
if d.Mentions != nil {
mentions := map[string]interface{}(d.Mentions)
if userIDs, ok := mentions["user_ids"]; ok {
if userIDList, ok := userIDs.([]interface{}); ok {
mentionedUsersList := make([]contract.UserResponse, 0)
for _, userID := range userIDList {
if userIDStr, ok := userID.(string); ok {
if userUUID, err := uuid.Parse(userIDStr); err == nil {
if user, exists := userMap[userUUID]; exists {
userResp := contract.UserResponse{
ID: user.ID,
Name: user.Name,
Email: user.Email,
IsActive: user.IsActive,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
// Include user profile if available
if user.Profile != nil {
userResp.Profile = &contract.UserProfileResponse{
UserID: user.Profile.UserID,
FullName: user.Profile.FullName,
DisplayName: user.Profile.DisplayName,
Phone: user.Profile.Phone,
AvatarURL: user.Profile.AvatarURL,
JobTitle: user.Profile.JobTitle,
EmployeeNo: user.Profile.EmployeeNo,
Bio: user.Profile.Bio,
Timezone: user.Profile.Timezone,
Locale: user.Profile.Locale,
}
}
mentionedUsersList = append(mentionedUsersList, userResp)
}
}
}
}
resp.MentionedUsers = mentionedUsersList
}
}
}
out = append(out, resp)
}
return out
}
func EnhancedDispositionsWithPreloadedDataToContract(list []entities.LetterIncomingDisposition) []contract.EnhancedDispositionResponse {
out := make([]contract.EnhancedDispositionResponse, 0, len(list))
for _, d := range list {
resp := contract.EnhancedDispositionResponse{
ID: d.ID,
LetterID: d.LetterID,
DepartmentID: d.DepartmentID,
Notes: d.Notes,
ReadAt: d.ReadAt,
CreatedBy: d.CreatedBy,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
Departments: []contract.DispositionDepartmentResponse{},
Actions: []contract.DispositionActionSelectionResponse{},
DispositionNotes: []contract.DispositionNoteResponse{},
Department: DepartmentToContract(d.Department),
}
if len(d.Departments) > 0 {
resp.Departments = DispositionDepartmentsWithDetailsToContract(d.Departments)
}
// Include preloaded action selections with details
if len(d.ActionSelections) > 0 {
resp.Actions = DispositionActionSelectionsWithDetailsToContract(d.ActionSelections)
}
// Include preloaded notes with user details
if len(d.DispositionNotes) > 0 {
resp.DispositionNotes = DispositionNotesWithDetailsToContract(d.DispositionNotes)
}
out = append(out, resp)
}
return out
}

View File

@ -37,9 +37,16 @@ func EntityToContract(user *entities.User) *contract.UserResponse {
if user == nil {
return nil
}
// Use Profile.FullName if available, otherwise fall back to user.Name
displayName := user.Name
if user.Profile != nil && user.Profile.FullName != "" {
displayName = user.Profile.FullName
}
resp := &contract.UserResponse{
ID: user.ID,
Name: user.Profile.FullName,
Name: displayName,
Email: user.Email,
IsActive: user.IsActive,
CreatedAt: user.CreatedAt,
@ -48,6 +55,9 @@ func EntityToContract(user *entities.User) *contract.UserResponse {
if user.Profile != nil {
resp.Profile = ProfileEntityToContract(user.Profile)
}
if user.Departments != nil && len(user.Departments) > 0 {
resp.DepartmentResponse = DepartmentsToContract(user.Departments)
}
return resp
}

View File

@ -5,7 +5,8 @@ DROP TABLE IF EXISTS letter_incoming_discussion_attachments;
DROP TABLE IF EXISTS letter_incoming_discussions;
DROP TABLE IF EXISTS letter_disposition_actions;
DROP TABLE IF EXISTS disposition_notes;
DROP TABLE IF EXISTS letter_dispositions;
DROP TABLE IF EXISTS letter_incoming_dispositions_department;
DROP TABLE IF EXISTS letter_incoming_dispositions;
DROP TABLE IF EXISTS letter_incoming_attachments;
DROP TABLE IF EXISTS letter_incoming_labels;
DROP TABLE IF EXISTS letter_incoming_recipients;

View File

@ -106,7 +106,7 @@ CREATE TRIGGER trg_letter_dispositions_updated_at
-- =======================
CREATE TABLE IF NOT EXISTS disposition_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
disposition_id UUID NOT NULL REFERENCES letter_dispositions(id) ON DELETE CASCADE,
disposition_id UUID NOT NULL REFERENCES letter_incoming_dispositions(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
note TEXT NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
@ -119,7 +119,7 @@ CREATE INDEX IF NOT EXISTS idx_disposition_notes_disposition ON disposition_note
-- =======================
CREATE TABLE IF NOT EXISTS letter_disposition_actions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
disposition_id UUID NOT NULL REFERENCES letter_dispositions(id) ON DELETE CASCADE,
disposition_id UUID NOT NULL REFERENCES letter_incoming_dispositions(id) ON DELETE CASCADE,
action_id UUID NOT NULL REFERENCES disposition_actions(id) ON DELETE RESTRICT,
note TEXT,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,

View File

@ -0,0 +1,41 @@
BEGIN;
-- =======================
-- DROP NEW ASSOCIATION TABLE
-- =======================
DROP TABLE IF EXISTS letter_incoming_dispositions_department;
-- =======================
-- RESTORE LETTER DISPOSITIONS TABLE STRUCTURE
-- =======================
-- Add back the columns that were removed
ALTER TABLE letter_incoming_dispositions ADD COLUMN IF NOT EXISTS from_user_id UUID REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE letter_incoming_dispositions ADD COLUMN IF NOT EXISTS to_user_id UUID REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE letter_incoming_dispositions ADD COLUMN IF NOT EXISTS to_department_id UUID REFERENCES departments(id) ON DELETE SET NULL;
ALTER TABLE letter_incoming_dispositions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','read','rejected','completed'));
ALTER TABLE letter_incoming_dispositions ADD COLUMN IF NOT EXISTS completed_at TIMESTAMP WITHOUT TIME ZONE;
-- Rename department_id back to from_department_id
ALTER TABLE letter_incoming_dispositions RENAME COLUMN department_id TO from_department_id;
-- =======================
-- RESTORE TRIGGERS AND INDEXES
-- =======================
-- Drop new trigger
DROP TRIGGER IF EXISTS trg_letter_incoming_dispositions_updated_at ON letter_incoming_dispositions;
-- Restore old trigger
CREATE TRIGGER trg_letter_dispositions_updated_at
BEFORE UPDATE ON letter_incoming_dispositions
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- Restore index names
DROP INDEX IF EXISTS idx_letter_incoming_dispositions_letter;
CREATE INDEX IF NOT EXISTS idx_letter_dispositions_letter ON letter_incoming_dispositions(letter_id);
-- =======================
-- RENAME TABLE BACK
-- =======================
ALTER TABLE letter_incoming_dispositions RENAME TO letter_dispositions;
COMMIT;

View File

@ -0,0 +1,54 @@
BEGIN;
-- =======================
-- RENAME LETTER DISPOSITIONS TABLE
-- =======================
ALTER TABLE letter_dispositions RENAME TO letter_incoming_dispositions;
-- =======================
-- MODIFY LETTER INCOMING DISPOSITIONS TABLE STRUCTURE
-- =======================
-- Drop existing columns that are not needed
ALTER TABLE letter_incoming_dispositions DROP COLUMN IF EXISTS from_user_id;
ALTER TABLE letter_incoming_dispositions DROP COLUMN IF EXISTS to_user_id;
ALTER TABLE letter_incoming_dispositions DROP COLUMN IF EXISTS to_department_id;
ALTER TABLE letter_incoming_dispositions DROP COLUMN IF EXISTS status;
ALTER TABLE letter_incoming_dispositions DROP COLUMN IF EXISTS completed_at;
-- Rename from_department_id to department_id
ALTER TABLE letter_incoming_dispositions RENAME COLUMN from_department_id TO department_id;
-- Add missing columns if they don't exist
ALTER TABLE letter_incoming_dispositions ADD COLUMN IF NOT EXISTS read_at TIMESTAMP WITHOUT TIME ZONE;
ALTER TABLE letter_incoming_dispositions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP;
-- =======================
-- CREATE LETTER INCOMING DISPOSITIONS DEPARTMENT ASSOCIATION TABLE
-- =======================
CREATE TABLE IF NOT EXISTS letter_incoming_dispositions_department (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_incoming_disposition_id UUID NOT NULL REFERENCES letter_incoming_dispositions(id) ON DELETE CASCADE,
department_id UUID NOT NULL REFERENCES departments(id) ON DELETE CASCADE,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE (letter_incoming_disposition_id, department_id)
);
CREATE INDEX IF NOT EXISTS idx_letter_incoming_dispositions_department_disposition ON letter_incoming_dispositions_department(letter_incoming_disposition_id);
CREATE INDEX IF NOT EXISTS idx_letter_incoming_dispositions_department_dept ON letter_incoming_dispositions_department(department_id);
-- =======================
-- UPDATE TRIGGERS AND INDEXES
-- =======================
-- Drop old trigger
DROP TRIGGER IF EXISTS trg_letter_dispositions_updated_at ON letter_incoming_dispositions;
-- Create new trigger
CREATE TRIGGER trg_letter_incoming_dispositions_updated_at
BEFORE UPDATE ON letter_incoming_dispositions
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- Update index names
DROP INDEX IF EXISTS idx_letter_dispositions_letter;
CREATE INDEX IF NOT EXISTS idx_letter_incoming_dispositions_letter ON letter_incoming_dispositions(letter_id);
COMMIT;

BIN
server Executable file

Binary file not shown.