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/cast v1.5.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // 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/subosito/gotenv v1.4.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // 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/aws/aws-sdk-go v1.55.7
github.com/golang-jwt/jwt/v5 v5.2.3 github.com/golang-jwt/jwt/v5 v5.2.3
github.com/sirupsen/logrus v1.9.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 go.uber.org/zap v1.21.0
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.28.0
gorm.io/driver/postgres v1.5.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/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.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.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.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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/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.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 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=

View File

@ -117,7 +117,8 @@ type repositories struct {
activityLogRepo *repository.LetterIncomingActivityLogRepository activityLogRepo *repository.LetterIncomingActivityLogRepository
dispositionRouteRepo *repository.DispositionRouteRepository dispositionRouteRepo *repository.DispositionRouteRepository
// new repos // new repos
letterDispositionRepo *repository.LetterDispositionRepository letterDispositionRepo *repository.LetterIncomingDispositionRepository
letterDispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository
letterDispActionSelRepo *repository.LetterDispositionActionSelectionRepository letterDispActionSelRepo *repository.LetterDispositionActionSelectionRepository
dispositionNoteRepo *repository.DispositionNoteRepository dispositionNoteRepo *repository.DispositionNoteRepository
letterDiscussionRepo *repository.LetterDiscussionRepository letterDiscussionRepo *repository.LetterDiscussionRepository
@ -141,7 +142,8 @@ func (a *App) initRepositories() *repositories {
letterAttachRepo: repository.NewLetterIncomingAttachmentRepository(a.db), letterAttachRepo: repository.NewLetterIncomingAttachmentRepository(a.db),
activityLogRepo: repository.NewLetterIncomingActivityLogRepository(a.db), activityLogRepo: repository.NewLetterIncomingActivityLogRepository(a.db),
dispositionRouteRepo: repository.NewDispositionRouteRepository(a.db), dispositionRouteRepo: repository.NewDispositionRouteRepository(a.db),
letterDispositionRepo: repository.NewLetterDispositionRepository(a.db), letterDispositionRepo: repository.NewLetterIncomingDispositionRepository(a.db),
letterDispositionDeptRepo: repository.NewLetterIncomingDispositionDepartmentRepository(a.db),
letterDispActionSelRepo: repository.NewLetterDispositionActionSelectionRepository(a.db), letterDispActionSelRepo: repository.NewLetterDispositionActionSelectionRepository(a.db),
dispositionNoteRepo: repository.NewDispositionNoteRepository(a.db), dispositionNoteRepo: repository.NewDispositionNoteRepository(a.db),
letterDiscussionRepo: repository.NewLetterDiscussionRepository(a.db), letterDiscussionRepo: repository.NewLetterDiscussionRepository(a.db),
@ -163,7 +165,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
activity := processor.NewActivityLogProcessor(repos.activityLogRepo) activity := processor.NewActivityLogProcessor(repos.activityLogRepo)
return &processors{ return &processors{
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo), 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, activityLogger: activity,
} }
} }

View File

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

View File

@ -19,8 +19,7 @@ var log *Logger
type ContextInfo struct { type ContextInfo struct {
CorrelationID string CorrelationID string
UserID uuid.UUID UserID uuid.UUID
OrganizationID uuid.UUID DepartmentID uuid.UUID
OutletID uuid.UUID
AppVersion string AppVersion string
AppID string AppID string
AppType string AppType string
@ -61,8 +60,7 @@ func FromGinContext(ctx context.Context) *ContextInfo {
return &ContextInfo{ return &ContextInfo{
CorrelationID: value(ctx, CorrelationIDKey), CorrelationID: value(ctx, CorrelationIDKey),
UserID: uuidValue(ctx, UserIDKey), UserID: uuidValue(ctx, UserIDKey),
OutletID: uuidValue(ctx, OutletIDKey), DepartmentID: uuidValue(ctx, DepartmentIDKey),
OrganizationID: uuidValue(ctx, OrganizationIDKey),
AppVersion: value(ctx, AppVersionKey), AppVersion: value(ctx, AppVersionKey),
AppID: value(ctx, AppIDKey), AppID: value(ctx, AppIDKey),
AppType: value(ctx, AppTypeKey), AppType: value(ctx, AppTypeKey),

View File

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

View File

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

View File

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

View File

@ -37,8 +37,8 @@ type IncomingLetterResponse struct {
ReferenceNumber *string `json:"reference_number,omitempty"` ReferenceNumber *string `json:"reference_number,omitempty"`
Subject string `json:"subject"` Subject string `json:"subject"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
PriorityID *uuid.UUID `json:"priority_id,omitempty"` Priority *PriorityResponse `json:"priority,omitempty"`
SenderInstitutionID *uuid.UUID `json:"sender_institution_id,omitempty"` SenderInstitution *InstitutionResponse `json:"sender_institution,omitempty"`
ReceivedDate time.Time `json:"received_date"` ReceivedDate time.Time `json:"received_date"`
DueDate *time.Time `json:"due_date,omitempty"` DueDate *time.Time `json:"due_date,omitempty"`
Status string `json:"status"` Status string `json:"status"`
@ -77,6 +77,7 @@ type CreateDispositionActionSelection struct {
} }
type CreateLetterDispositionRequest struct { type CreateLetterDispositionRequest struct {
FromDepartment uuid.UUID `json:"from_department"`
LetterID uuid.UUID `json:"letter_id"` LetterID uuid.UUID `json:"letter_id"`
ToDepartmentIDs []uuid.UUID `json:"to_department_ids"` ToDepartmentIDs []uuid.UUID `json:"to_department_ids"`
Notes *string `json:"notes,omitempty"` Notes *string `json:"notes,omitempty"`
@ -86,18 +87,62 @@ type CreateLetterDispositionRequest struct {
type DispositionResponse struct { type DispositionResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
LetterID uuid.UUID `json:"letter_id"` LetterID uuid.UUID `json:"letter_id"`
FromDepartmentID *uuid.UUID `json:"from_department_id,omitempty"` DepartmentID *uuid.UUID `json:"department_id,omitempty"`
ToDepartmentID *uuid.UUID `json:"to_department_id,omitempty"`
Notes *string `json:"notes,omitempty"` Notes *string `json:"notes,omitempty"`
Status string `json:"status"` ReadAt *time.Time `json:"read_at,omitempty"`
CreatedBy uuid.UUID `json:"created_by"` CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
type ListDispositionsResponse struct { type ListDispositionsResponse struct {
Dispositions []DispositionResponse `json:"dispositions"` 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 { type CreateLetterDiscussionRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"` ParentID *uuid.UUID `json:"parent_id,omitempty"`
Message string `json:"message"` Message string `json:"message"`
@ -119,4 +164,10 @@ type LetterDiscussionResponse struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
EditedAt *time.Time `json:"edited_at,omitempty"` 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

@ -55,6 +55,7 @@ type UserResponse struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Roles []RoleResponse `json:"roles,omitempty"` Roles []RoleResponse `json:"roles,omitempty"`
DepartmentResponse []DepartmentResponse `json:"department_response"`
Profile *UserProfileResponse `json:"profile,omitempty"` Profile *UserProfileResponse `json:"profile,omitempty"`
} }
@ -128,3 +129,15 @@ type TitleResponse struct {
type ListTitlesResponse struct { type ListTitlesResponse struct {
Titles []TitleResponse `json:"titles"` 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"` AllowedActions JSONB `gorm:"type:jsonb" json:"allowed_actions,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_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" } func (DispositionRoute) TableName() string { return "disposition_routes" }

View File

@ -16,6 +16,9 @@ type LetterDiscussion struct {
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
EditedAt *time.Time `json:"edited_at,omitempty"` 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" } func (LetterDiscussion) TableName() string { return "letter_incoming_discussions" }

View File

@ -6,32 +6,36 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type LetterDispositionStatus string type LetterIncomingDisposition struct {
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"` 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"` LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"`
FromUserID *uuid.UUID `json:"from_user_id,omitempty"` DepartmentID *uuid.UUID `json:"department_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"` 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"` ReadAt *time.Time `json:"read_at,omitempty"`
CompletedAt *time.Time `json:"completed_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"` 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 { type DispositionNote struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` 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"` UserID *uuid.UUID `json:"user_id,omitempty"`
Note string `gorm:"not null" json:"note"` Note string `gorm:"not null" json:"note"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` 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" } func (DispositionNote) TableName() string { return "disposition_notes" }
@ -50,6 +57,9 @@ type LetterDispositionActionSelection struct {
Note *string `json:"note,omitempty"` Note *string `json:"note,omitempty"`
CreatedBy uuid.UUID `gorm:"not null" json:"created_by"` CreatedBy uuid.UUID `gorm:"not null" json:"created_by"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` 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" } 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"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Profile *UserProfile `gorm:"foreignKey:UserID;references:ID" json:"profile,omitempty"` 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 { func (u *User) BeforeCreate(tx *gorm.DB) error {

View File

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

View File

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

View File

@ -24,7 +24,7 @@ type MasterService interface {
CreateInstitution(ctx context.Context, req *contract.CreateInstitutionRequest) (*contract.InstitutionResponse, error) CreateInstitution(ctx context.Context, req *contract.CreateInstitutionRequest) (*contract.InstitutionResponse, error)
UpdateInstitution(ctx context.Context, id uuid.UUID, req *contract.UpdateInstitutionRequest) (*contract.InstitutionResponse, error) UpdateInstitution(ctx context.Context, id uuid.UUID, req *contract.UpdateInstitutionRequest) (*contract.InstitutionResponse, error)
DeleteInstitution(ctx context.Context, id uuid.UUID) 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) CreateDispositionAction(ctx context.Context, req *contract.CreateDispositionActionRequest) (*contract.DispositionActionResponse, error)
UpdateDispositionAction(ctx context.Context, id uuid.UUID, req *contract.UpdateDispositionActionRequest) (*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) { 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 { if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return 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) { 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 { if err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::ListTitles -> Failed to get titles from service")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return 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) { 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) UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error)
ListTitles(ctx context.Context) (*contract.ListTitlesResponse, 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()) 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 { if roles, perms, err := m.authService.ExtractAccess(token); err == nil {
c.Set("user_roles", roles) c.Set("user_roles", roles)

View File

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

View File

@ -19,21 +19,22 @@ type LetterProcessorImpl struct {
attachRepo *repository.LetterIncomingAttachmentRepository attachRepo *repository.LetterIncomingAttachmentRepository
txManager *repository.TxManager txManager *repository.TxManager
activity *ActivityLogProcessorImpl activity *ActivityLogProcessorImpl
// new repos for dispositions dispositionRepo *repository.LetterIncomingDispositionRepository
dispositionRepo *repository.LetterDispositionRepository dispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository
dispositionActionSelRepo *repository.LetterDispositionActionSelectionRepository dispositionActionSelRepo *repository.LetterDispositionActionSelectionRepository
dispositionNoteRepo *repository.DispositionNoteRepository dispositionNoteRepo *repository.DispositionNoteRepository
// discussion repo
discussionRepo *repository.LetterDiscussionRepository discussionRepo *repository.LetterDiscussionRepository
// settings and recipients
settingRepo *repository.AppSettingRepository settingRepo *repository.AppSettingRepository
recipientRepo *repository.LetterIncomingRecipientRepository recipientRepo *repository.LetterIncomingRecipientRepository
departmentRepo *repository.DepartmentRepository departmentRepo *repository.DepartmentRepository
userDeptRepo *repository.UserDepartmentRepository 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 { 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, dispositionActionSelRepo: dispSelRepo, dispositionNoteRepo: noteRepo, discussionRepo: discussionRepo, settingRepo: settingRepo, recipientRepo: recipientRepo, departmentRepo: departmentRepo, userDeptRepo: userDeptRepo} 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) { 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)) depIDs := make([]uuid.UUID, 0, len(defaultDeptCodes))
for _, code := range defaultDeptCodes { for _, code := range defaultDeptCodes {
dep, err := p.departmentRepo.GetByCode(txCtx, code) 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) depIDs = append(depIDs, dep.ID)
} }
// query user memberships for all departments at once
userMemberships, _ := p.userDeptRepo.ListActiveByDepartmentIDs(txCtx, depIDs) userMemberships, _ := p.userDeptRepo.ListActiveByDepartmentIDs(txCtx, depIDs)
// build recipients: one department recipient per department + one user recipient per membership var recipients []entities.LetterIncomingRecipient
recipients := make([]entities.LetterIncomingRecipient, 0, len(depIDs)+len(userMemberships))
// department recipients mapsUsers := map[string]bool{}
for _, depID := range depIDs {
id := depID
recipients = append(recipients, entities.LetterIncomingRecipient{LetterID: entity.ID, RecipientDepartmentID: &id, Status: entities.RecipientStatusNew})
}
// user recipients
for _, row := range userMemberships { for _, row := range userMemberships {
uid := row.UserID 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 len(recipients) > 0 {
if err := p.recipientRepo.CreateBulk(txCtx, recipients); err != nil { if err := p.recipientRepo.CreateBulk(txCtx, recipients); err != nil {
return err return err
@ -141,9 +140,26 @@ func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *con
} }
savedAttachments, _ := p.attachRepo.ListByLetter(txCtx, entity.ID) 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 return nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -156,7 +172,19 @@ func (p *LetterProcessorImpl) GetIncomingLetterByID(ctx context.Context, id uuid
return nil, err return nil, err
} }
atts, _ := p.attachRepo.ListByLetter(ctx, id) 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) { 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)) respList := make([]contract.IncomingLetterResponse, 0, len(list))
for _, e := range list { for _, e := range list {
atts, _ := p.attachRepo.ListByLetter(ctx, e.ID) 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) respList = append(respList, *resp)
} }
return &contract.ListIncomingLettersResponse{Letters: respList, Pagination: transformer.CreatePaginationResponse(int(total), page, limit)}, nil 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) 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 return nil
}) })
if err != nil { if err != nil {
@ -254,20 +306,28 @@ func (p *LetterProcessorImpl) CreateDispositions(ctx context.Context, req *contr
var out *contract.ListDispositionsResponse var out *contract.ListDispositionsResponse
err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
userID := appcontext.FromGinContext(txCtx).UserID userID := appcontext.FromGinContext(txCtx).UserID
created := make([]entities.LetterDisposition, 0, len(req.ToDepartmentIDs))
for _, toDept := range req.ToDepartmentIDs { disp := entities.LetterIncomingDisposition{
disp := entities.LetterDisposition{
LetterID: req.LetterID, LetterID: req.LetterID,
FromDepartmentID: nil, DepartmentID: &req.FromDepartment,
ToDepartmentID: &toDept,
Notes: req.Notes, Notes: req.Notes,
Status: entities.DispositionPending,
CreatedBy: userID, CreatedBy: userID,
} }
if err := p.dispositionRepo.Create(txCtx, &disp); err != nil { if err := p.dispositionRepo.Create(txCtx, &disp); err != nil {
return err return err
} }
created = append(created, disp)
var dispDepartments []entities.LetterIncomingDispositionDepartment
for _, toDept := range req.ToDepartmentIDs {
dispDepartments = append(dispDepartments, entities.LetterIncomingDispositionDepartment{
LetterIncomingDispositionID: disp.ID,
DepartmentID: toDept,
})
}
if err := p.dispositionDeptRepo.CreateBulk(txCtx, dispDepartments); err != nil {
return err
}
if len(req.SelectedActions) > 0 { if len(req.SelectedActions) > 0 {
selections := make([]entities.LetterDispositionActionSelection, 0, len(req.SelectedActions)) selections := make([]entities.LetterDispositionActionSelection, 0, len(req.SelectedActions))
@ -286,16 +346,13 @@ func (p *LetterProcessorImpl) CreateDispositions(ctx context.Context, req *contr
if p.activity != nil { if p.activity != nil {
action := "disposition.created" action := "disposition.created"
for _, d := range created { ctxMap := map[string]interface{}{"to_department_id": dispDepartments}
ctxMap := map[string]interface{}{"to_department_id": d.ToDepartmentID} if err := p.activity.Log(txCtx, req.LetterID, action, &userID, nil, nil, &disp.ID, nil, nil, ctxMap); err != nil {
if err := p.activity.Log(txCtx, req.LetterID, action, &userID, nil, nil, &d.ID, nil, nil, ctxMap); err != nil {
return err return err
} }
} }
}
}
out = &contract.ListDispositionsResponse{Dispositions: transformer.DispositionsToContract(created)} out = &contract.ListDispositionsResponse{Dispositions: []contract.DispositionResponse{transformer.DispoToContract(disp)}}
return nil return nil
}) })
if err != nil { if err != nil {
@ -312,6 +369,64 @@ func (p *LetterProcessorImpl) ListDispositionsByLetter(ctx context.Context, lett
return &contract.ListDispositionsResponse{Dispositions: transformer.DispositionsToContract(list)}, nil 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) { func (p *LetterProcessorImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) {
var out *contract.LetterDiscussionResponse var out *contract.LetterDiscussionResponse
err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { 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 { if req.Mentions != nil {
mentions = entities.JSONB(req.Mentions) 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 { if err := p.discussionRepo.Create(txCtx, disc); err != nil {
return err return err
} }

View File

@ -112,9 +112,11 @@ func (p *UserProcessorImpl) GetUserByID(ctx context.Context, id uuid.UUID) (*con
} }
resp := transformer.EntityToContract(user) resp := transformer.EntityToContract(user)
if resp != nil { if resp != nil {
// Roles are loaded separately since they're not preloaded
if roles, err := p.userRepo.GetRolesByUserID(ctx, resp.ID); err == nil { if roles, err := p.userRepo.GetRolesByUserID(ctx, resp.ID); err == nil {
resp.Roles = transformer.RolesToContract(roles) resp.Roles = transformer.RolesToContract(roles)
} }
// Departments are now preloaded, so they're already in the response
} }
return resp, nil 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) 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 return transformer.EntityToContract(user), nil
} }
@ -149,6 +152,7 @@ func (p *UserProcessorImpl) ListUsersWithFilters(ctx context.Context, req *contr
for i := range responses { for i := range responses {
userIDs = append(userIDs, responses[i].ID) userIDs = append(userIDs, responses[i].ID)
} }
// Roles are loaded separately since they're not preloaded
rolesMap, err := p.userRepo.GetRolesByUserIDs(ctx, userIDs) rolesMap, err := p.userRepo.GetRolesByUserIDs(ctx, userIDs)
if err == nil { if err == nil {
for i := range responses { 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 return responses, int(totalCount), nil
} }
@ -272,3 +277,38 @@ func (p *UserProcessorImpl) UpdateUserProfile(ctx context.Context, userID uuid.U
} }
return transformer.ProfileEntityToContract(entity), nil 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) { func (r *DispositionRouteRepository) Get(ctx context.Context, id uuid.UUID) (*entities.DispositionRoute, error) {
db := DBFromContext(ctx, r.db) db := DBFromContext(ctx, r.db)
var e entities.DispositionRoute 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 nil, err
} }
return &e, nil 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) { func (r *DispositionRouteRepository) ListByFromDept(ctx context.Context, fromDept uuid.UUID) ([]entities.DispositionRoute, error) {
db := DBFromContext(ctx, r.db) db := DBFromContext(ctx, r.db)
var list []entities.DispositionRoute 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 nil, err
} }
return list, nil return list, nil
} }
func (r *DispositionRouteRepository) SetActive(ctx context.Context, id uuid.UUID, isActive bool) error { func (r *DispositionRouteRepository) SetActive(ctx context.Context, id uuid.UUID, isActive bool) error {
db := DBFromContext(ctx, r.db) db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Model(&entities.DispositionRoute{}).Where("id = ?", id).Update("is_active", isActive).Error 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 return list, nil
} }
type LetterDispositionRepository struct{ db *gorm.DB } type LetterIncomingDispositionRepository struct{ db *gorm.DB }
func NewLetterDispositionRepository(db *gorm.DB) *LetterDispositionRepository { func NewLetterIncomingDispositionRepository(db *gorm.DB) *LetterIncomingDispositionRepository {
return &LetterDispositionRepository{db: db} 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) db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(e).Error 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) db := DBFromContext(ctx, r.db)
var list []entities.LetterDisposition var list []entities.LetterIncomingDisposition
if err := db.WithContext(ctx).Where("letter_id = ?", letterID).Order("created_at ASC").Find(&list).Error; err != nil { 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 nil, err
} }
return list, nil return list, nil
@ -132,6 +169,27 @@ func (r *DispositionNoteRepository) Create(ctx context.Context, e *entities.Disp
return db.WithContext(ctx).Create(e).Error 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 } type LetterDispositionActionSelectionRepository struct{ db *gorm.DB }
func NewLetterDispositionActionSelectionRepository(db *gorm.DB) *LetterDispositionActionSelectionRepository { func NewLetterDispositionActionSelectionRepository(db *gorm.DB) *LetterDispositionActionSelectionRepository {
@ -150,6 +208,18 @@ func (r *LetterDispositionActionSelectionRepository) ListByDisposition(ctx conte
return list, nil 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 } type LetterDiscussionRepository struct{ db *gorm.DB }
func NewLetterDiscussionRepository(db *gorm.DB) *LetterDiscussionRepository { 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 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 } type AppSettingRepository struct{ db *gorm.DB }
func NewAppSettingRepository(db *gorm.DB) *AppSettingRepository { return &AppSettingRepository{db: 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 err := r.db.WithContext(ctx).Order("name ASC").Find(&list).Error
return list, err 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) { func (r *InstitutionRepository) Get(ctx context.Context, id uuid.UUID) (*entities.Institution, error) {
var e entities.Institution var e entities.Institution
if err := r.db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil { 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 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 } type DepartmentRepository struct{ db *gorm.DB }
func NewDepartmentRepository(db *gorm.DB) *DepartmentRepository { return &DepartmentRepository{db: 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 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) { func (r *UserRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.User, error) {
var user entities.User 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 { if err != nil {
return nil, err 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) { func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*entities.User, error) {
var user entities.User 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 { if err != nil {
return nil, err 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) { func (r *UserRepositoryImpl) GetByRole(ctx context.Context, role entities.UserRole) ([]*entities.User, error) {
var users []*entities.User 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 return users, err
} }
@ -52,6 +58,7 @@ func (r *UserRepositoryImpl) GetActiveUsers(ctx context.Context, organizationID
err := r.b.WithContext(ctx). err := r.b.WithContext(ctx).
Where(" is_active = ?", organizationID, true). Where(" is_active = ?", organizationID, true).
Preload("Profile"). Preload("Profile").
Preload("Departments").
Find(&users).Error Find(&users).Error
return users, err return users, err
} }
@ -90,7 +97,7 @@ func (r *UserRepositoryImpl) List(ctx context.Context, filters map[string]interf
return nil, 0, err 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 return users, total, err
} }
@ -141,19 +148,19 @@ func (r *UserRepositoryImpl) GetDepartmentsByUserID(ctx context.Context, userID
return departments, err 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) { func (r *UserRepositoryImpl) GetRolesByUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]entities.Role, error) {
result := make(map[uuid.UUID][]entities.Role) result := make(map[uuid.UUID][]entities.Role)
if len(userIDs) == 0 { if len(userIDs) == 0 {
return result, nil return result, nil
} }
// fetch pairs user_id, role
type row struct { type row struct {
UserID uuid.UUID UserID uuid.UUID
RoleID uuid.UUID RoleID uuid.UUID
Name string Name string
Code string Code string
} }
var rows []row var rows []row
err := r.b.WithContext(ctx). err := r.b.WithContext(ctx).
Table("user_role as ur"). Table("user_role as ur").
@ -171,7 +178,6 @@ func (r *UserRepositoryImpl) GetRolesByUserIDs(ctx context.Context, userIDs []uu
return result, nil 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) { 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 users []*entities.User
var total int64 var total int64
@ -194,7 +200,7 @@ func (r *UserRepositoryImpl) ListWithFilters(ctx context.Context, search *string
return nil, 0, err 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 nil, 0, err
} }
return users, total, nil return users, total, nil

View File

@ -12,6 +12,7 @@ type UserHandler interface {
UpdateProfile(c *gin.Context) UpdateProfile(c *gin.Context)
ChangePassword(c *gin.Context) ChangePassword(c *gin.Context)
ListTitles(c *gin.Context) ListTitles(c *gin.Context)
GetActiveUsersForMention(c *gin.Context)
} }
type FileHandler interface { type FileHandler interface {
@ -62,7 +63,8 @@ type LetterHandler interface {
DeleteIncomingLetter(c *gin.Context) DeleteIncomingLetter(c *gin.Context)
CreateDispositions(c *gin.Context) CreateDispositions(c *gin.Context)
ListDispositionsByLetter(c *gin.Context) //ListDispositionsByLetter(c *gin.Context)
GetEnhancedDispositionsByLetter(c *gin.Context)
CreateDiscussion(c *gin.Context) CreateDiscussion(c *gin.Context)
UpdateDiscussion(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("/profile", r.userHandler.UpdateProfile)
users.PUT(":id/password", r.userHandler.ChangePassword) users.PUT(":id/password", r.userHandler.ChangePassword)
users.GET("/titles", r.userHandler.ListTitles) users.GET("/titles", r.userHandler.ListTitles)
users.GET("/mention", r.userHandler.GetActiveUsersForMention)
users.POST("/profile/avatar", r.fileHandler.UploadProfileAvatar) 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.DELETE("/incoming/:id", r.letterHandler.DeleteIncomingLetter)
lettersch.POST("/dispositions/:letter_id", r.letterHandler.CreateDispositions) 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.POST("/discussions/:letter_id", r.letterHandler.CreateDiscussion)
lettersch.PUT("/discussions/:letter_id/:discussion_id", r.letterHandler.UpdateDiscussion) 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.POST("", r.dispRouteHandler.Create)
droutes.GET(":id", r.dispRouteHandler.Get) droutes.GET(":id", r.dispRouteHandler.Get)
droutes.PUT(":id", r.dispRouteHandler.Update) 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) 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) roles, _ := s.userProcessor.GetUserRoles(ctx, userResponse.ID)
permCodes, _ := s.userProcessor.GetUserPermissionCodes(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) token, expiresAt, err := s.generateToken(userResponse, roles, permCodes)
if err != nil { if err != nil {
@ -71,7 +71,7 @@ func (s *AuthServiceImpl) Login(ctx context.Context, req *contract.LoginRequest)
User: *userResponse, User: *userResponse,
Roles: roles, Roles: roles,
Permissions: permCodes, Permissions: permCodes,
Departments: departments, Departments: userResponse.DepartmentResponse,
}, nil }, nil
} }
@ -90,6 +90,7 @@ func (s *AuthServiceImpl) ValidateToken(tokenString string) (*contract.UserRespo
return nil, fmt.Errorf("user account is deactivated") return nil, fmt.Errorf("user account is deactivated")
} }
// Departments are now preloaded, so they're already in the response
return userResponse, nil 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) 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{ return &contract.LoginResponse{
Token: newToken, Token: newToken,
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
User: *userResponse, User: *userResponse,
Roles: roles, Roles: roles,
Permissions: permCodes, Permissions: permCodes,
Departments: departments, Departments: userResponse.DepartmentResponse,
}, nil }, nil
} }

View File

@ -16,7 +16,7 @@ type LetterProcessor interface {
SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error
CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, 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) 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) 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) return s.processor.CreateDispositions(ctx, req)
} }
func (s *LetterServiceImpl) ListDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListDispositionsResponse, error) { func (s *LetterServiceImpl) GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) {
return s.processor.ListDispositionsByLetter(ctx, letterID) return s.processor.GetEnhancedDispositionsByLetter(ctx, letterID)
} }
func (s *LetterServiceImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) { 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 { func (s *MasterServiceImpl) DeleteInstitution(ctx context.Context, id uuid.UUID) error {
return s.institutionRepo.Delete(ctx, id) return s.institutionRepo.Delete(ctx, id)
} }
func (s *MasterServiceImpl) ListInstitutions(ctx context.Context) (*contract.ListInstitutionsResponse, error) { func (s *MasterServiceImpl) ListInstitutions(ctx context.Context, req *contract.ListInstitutionsRequest) (*contract.ListInstitutionsResponse, error) {
list, err := s.institutionRepo.List(ctx) list, err := s.institutionRepo.ListWithSearch(ctx, req.Search)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -26,4 +26,7 @@ type UserProcessor interface {
// New optimized listing // New optimized listing
ListUsersWithFilters(ctx context.Context, req *contract.ListUsersRequest) ([]contract.UserResponse, int, error) 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 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 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 { func ProfileEntityToContract(p *entities.UserProfile) *contract.UserProfileResponse {
if p == nil { if p == nil {
return nil return nil
@ -241,7 +245,8 @@ func DispositionRoutesToContract(list []entities.DispositionRoute) []contract.Di
if e.AllowedActions != nil { if e.AllowedActions != nil {
allowed = map[string]interface{}(e.AllowedActions) allowed = map[string]interface{}(e.AllowedActions)
} }
out = append(out, contract.DispositionRouteResponse{
resp := contract.DispositionRouteResponse{
ID: e.ID, ID: e.ID,
FromDepartmentID: e.FromDepartmentID, FromDepartmentID: e.FromDepartmentID,
ToDepartmentID: e.ToDepartmentID, ToDepartmentID: e.ToDepartmentID,
@ -249,7 +254,26 @@ func DispositionRoutesToContract(list []entities.DispositionRoute) []contract.Di
AllowedActions: allowed, AllowedActions: allowed,
CreatedAt: e.CreatedAt, CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt, 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 return out
} }

View File

@ -3,17 +3,17 @@ package transformer
import ( import (
"eslogad-be/internal/contract" "eslogad-be/internal/contract"
"eslogad-be/internal/entities" "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{ resp := &contract.IncomingLetterResponse{
ID: e.ID, ID: e.ID,
LetterNumber: e.LetterNumber, LetterNumber: e.LetterNumber,
ReferenceNumber: e.ReferenceNumber, ReferenceNumber: e.ReferenceNumber,
Subject: e.Subject, Subject: e.Subject,
Description: e.Description, Description: e.Description,
PriorityID: e.PriorityID,
SenderInstitutionID: e.SenderInstitutionID,
ReceivedDate: e.ReceivedDate, ReceivedDate: e.ReceivedDate,
DueDate: e.DueDate, DueDate: e.DueDate,
Status: string(e.Status), Status: string(e.Status),
@ -22,6 +22,37 @@ func LetterEntityToContract(e *entities.LetterIncoming, attachments []entities.L
UpdatedAt: e.UpdatedAt, UpdatedAt: e.UpdatedAt,
Attachments: make([]contract.IncomingLetterAttachmentResponse, 0, len(attachments)), 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 { for _, a := range attachments {
resp.Attachments = append(resp.Attachments, contract.IncomingLetterAttachmentResponse{ resp.Attachments = append(resp.Attachments, contract.IncomingLetterAttachmentResponse{
ID: a.ID, ID: a.ID,
@ -34,19 +65,171 @@ func LetterEntityToContract(e *entities.LetterIncoming, attachments []entities.L
return resp return resp
} }
func DispositionsToContract(list []entities.LetterDisposition) []contract.DispositionResponse { func DispositionsToContract(list []entities.LetterIncomingDisposition) []contract.DispositionResponse {
out := make([]contract.DispositionResponse, 0, len(list)) out := make([]contract.DispositionResponse, 0, len(list))
for _, d := range 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, ID: d.ID,
LetterID: d.LetterID, LetterID: d.LetterID,
FromDepartmentID: d.FromDepartmentID, DepartmentID: d.DepartmentID,
ToDepartmentID: d.ToDepartmentID,
Notes: d.Notes, Notes: d.Notes,
Status: string(d.Status), ReadAt: d.ReadAt,
CreatedBy: d.CreatedBy, CreatedBy: d.CreatedBy,
CreatedAt: d.CreatedAt, 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,
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{},
}
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 return out
} }
@ -68,3 +251,138 @@ func DiscussionEntityToContract(e *entities.LetterDiscussion) *contract.LetterDi
EditedAt: e.EditedAt, 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 { if user == nil {
return 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{ resp := &contract.UserResponse{
ID: user.ID, ID: user.ID,
Name: user.Profile.FullName, Name: displayName,
Email: user.Email, Email: user.Email,
IsActive: user.IsActive, IsActive: user.IsActive,
CreatedAt: user.CreatedAt, CreatedAt: user.CreatedAt,
@ -48,6 +55,9 @@ func EntityToContract(user *entities.User) *contract.UserResponse {
if user.Profile != nil { if user.Profile != nil {
resp.Profile = ProfileEntityToContract(user.Profile) resp.Profile = ProfileEntityToContract(user.Profile)
} }
if user.Departments != nil && len(user.Departments) > 0 {
resp.DepartmentResponse = DepartmentsToContract(user.Departments)
}
return resp 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_incoming_discussions;
DROP TABLE IF EXISTS letter_disposition_actions; DROP TABLE IF EXISTS letter_disposition_actions;
DROP TABLE IF EXISTS disposition_notes; 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_attachments;
DROP TABLE IF EXISTS letter_incoming_labels; DROP TABLE IF EXISTS letter_incoming_labels;
DROP TABLE IF EXISTS letter_incoming_recipients; 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 ( CREATE TABLE IF NOT EXISTS disposition_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, user_id UUID REFERENCES users(id) ON DELETE SET NULL,
note TEXT NOT NULL, note TEXT NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP 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 ( CREATE TABLE IF NOT EXISTS letter_disposition_actions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, action_id UUID NOT NULL REFERENCES disposition_actions(id) ON DELETE RESTRICT,
note TEXT, note TEXT,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, 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.