diff --git a/MIGRATION_SUMMARY.md b/MIGRATION_SUMMARY.md new file mode 100644 index 0000000..a612e8f --- /dev/null +++ b/MIGRATION_SUMMARY.md @@ -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 diff --git a/go.mod b/go.mod index 59978db..881c7af 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/spf13/cast v1.5.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/objx v0.5.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.4.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect @@ -64,7 +64,7 @@ require ( github.com/aws/aws-sdk-go v1.55.7 github.com/golang-jwt/jwt/v5 v5.2.3 github.com/sirupsen/logrus v1.9.3 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.10.0 go.uber.org/zap v1.21.0 golang.org/x/crypto v0.28.0 gorm.io/driver/postgres v1.5.0 diff --git a/go.sum b/go.sum index 6cc295c..02f23ca 100644 --- a/go.sum +++ b/go.sum @@ -236,8 +236,9 @@ github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc= github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -247,8 +248,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= diff --git a/internal/app/app.go b/internal/app/app.go index 45c7f1b..e8062b3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -117,38 +117,40 @@ type repositories struct { activityLogRepo *repository.LetterIncomingActivityLogRepository dispositionRouteRepo *repository.DispositionRouteRepository // new repos - letterDispositionRepo *repository.LetterDispositionRepository - letterDispActionSelRepo *repository.LetterDispositionActionSelectionRepository - dispositionNoteRepo *repository.DispositionNoteRepository - letterDiscussionRepo *repository.LetterDiscussionRepository - settingRepo *repository.AppSettingRepository - recipientRepo *repository.LetterIncomingRecipientRepository - departmentRepo *repository.DepartmentRepository - userDeptRepo *repository.UserDepartmentRepository + letterDispositionRepo *repository.LetterIncomingDispositionRepository + letterDispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository + letterDispActionSelRepo *repository.LetterDispositionActionSelectionRepository + dispositionNoteRepo *repository.DispositionNoteRepository + letterDiscussionRepo *repository.LetterDiscussionRepository + settingRepo *repository.AppSettingRepository + recipientRepo *repository.LetterIncomingRecipientRepository + departmentRepo *repository.DepartmentRepository + userDeptRepo *repository.UserDepartmentRepository } func (a *App) initRepositories() *repositories { return &repositories{ - userRepo: repository.NewUserRepository(a.db), - userProfileRepo: repository.NewUserProfileRepository(a.db), - titleRepo: repository.NewTitleRepository(a.db), - rbacRepo: repository.NewRBACRepository(a.db), - labelRepo: repository.NewLabelRepository(a.db), - priorityRepo: repository.NewPriorityRepository(a.db), - institutionRepo: repository.NewInstitutionRepository(a.db), - dispRepo: repository.NewDispositionActionRepository(a.db), - letterRepo: repository.NewLetterIncomingRepository(a.db), - letterAttachRepo: repository.NewLetterIncomingAttachmentRepository(a.db), - activityLogRepo: repository.NewLetterIncomingActivityLogRepository(a.db), - dispositionRouteRepo: repository.NewDispositionRouteRepository(a.db), - letterDispositionRepo: repository.NewLetterDispositionRepository(a.db), - letterDispActionSelRepo: repository.NewLetterDispositionActionSelectionRepository(a.db), - dispositionNoteRepo: repository.NewDispositionNoteRepository(a.db), - letterDiscussionRepo: repository.NewLetterDiscussionRepository(a.db), - settingRepo: repository.NewAppSettingRepository(a.db), - recipientRepo: repository.NewLetterIncomingRecipientRepository(a.db), - departmentRepo: repository.NewDepartmentRepository(a.db), - userDeptRepo: repository.NewUserDepartmentRepository(a.db), + userRepo: repository.NewUserRepository(a.db), + userProfileRepo: repository.NewUserProfileRepository(a.db), + titleRepo: repository.NewTitleRepository(a.db), + rbacRepo: repository.NewRBACRepository(a.db), + labelRepo: repository.NewLabelRepository(a.db), + priorityRepo: repository.NewPriorityRepository(a.db), + institutionRepo: repository.NewInstitutionRepository(a.db), + dispRepo: repository.NewDispositionActionRepository(a.db), + letterRepo: repository.NewLetterIncomingRepository(a.db), + letterAttachRepo: repository.NewLetterIncomingAttachmentRepository(a.db), + activityLogRepo: repository.NewLetterIncomingActivityLogRepository(a.db), + dispositionRouteRepo: repository.NewDispositionRouteRepository(a.db), + letterDispositionRepo: repository.NewLetterIncomingDispositionRepository(a.db), + letterDispositionDeptRepo: repository.NewLetterIncomingDispositionDepartmentRepository(a.db), + letterDispActionSelRepo: repository.NewLetterDispositionActionSelectionRepository(a.db), + dispositionNoteRepo: repository.NewDispositionNoteRepository(a.db), + letterDiscussionRepo: repository.NewLetterDiscussionRepository(a.db), + settingRepo: repository.NewAppSettingRepository(a.db), + recipientRepo: repository.NewLetterIncomingRecipientRepository(a.db), + departmentRepo: repository.NewDepartmentRepository(a.db), + userDeptRepo: repository.NewUserDepartmentRepository(a.db), } } @@ -163,7 +165,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor activity := processor.NewActivityLogProcessor(repos.activityLogRepo) return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo), - letterProcessor: processor.NewLetterProcessor(repos.letterRepo, repos.letterAttachRepo, txMgr, activity, repos.letterDispositionRepo, repos.letterDispActionSelRepo, repos.dispositionNoteRepo, repos.letterDiscussionRepo, repos.settingRepo, repos.recipientRepo, repos.departmentRepo, repos.userDeptRepo), + letterProcessor: processor.NewLetterProcessor(repos.letterRepo, repos.letterAttachRepo, txMgr, activity, repos.letterDispositionRepo, repos.letterDispositionDeptRepo, repos.letterDispActionSelRepo, repos.dispositionNoteRepo, repos.letterDiscussionRepo, repos.settingRepo, repos.recipientRepo, repos.departmentRepo, repos.userDeptRepo, repos.priorityRepo, repos.institutionRepo, repos.dispRepo), activityLogger: activity, } } diff --git a/internal/appcontext/context.go b/internal/appcontext/context.go index dd35443..d719520 100644 --- a/internal/appcontext/context.go +++ b/internal/appcontext/context.go @@ -12,7 +12,7 @@ const ( CorrelationIDKey = key("CorrelationID") OrganizationIDKey = key("OrganizationIDKey") UserIDKey = key("UserID") - OutletIDKey = key("OutletID") + DepartmentIDKey = key("DepartmentID") RoleIDKey = key("RoleID") AppVersionKey = key("AppVersion") AppIDKey = key("AppID") @@ -27,7 +27,7 @@ func LogFields(ctx interface{}) map[string]interface{} { fields := make(map[string]interface{}) fields[string(CorrelationIDKey)] = value(ctx, CorrelationIDKey) fields[string(OrganizationIDKey)] = value(ctx, OrganizationIDKey) - fields[string(OutletIDKey)] = value(ctx, OutletIDKey) + fields[string(DepartmentIDKey)] = value(ctx, DepartmentIDKey) fields[string(AppVersionKey)] = value(ctx, AppVersionKey) fields[string(AppIDKey)] = value(ctx, AppIDKey) fields[string(AppTypeKey)] = value(ctx, AppTypeKey) diff --git a/internal/appcontext/context_info.go b/internal/appcontext/context_info.go index 63f6365..fa29de5 100644 --- a/internal/appcontext/context_info.go +++ b/internal/appcontext/context_info.go @@ -17,17 +17,16 @@ type Logger struct { var log *Logger type ContextInfo struct { - CorrelationID string - UserID uuid.UUID - OrganizationID uuid.UUID - OutletID uuid.UUID - AppVersion string - AppID string - AppType string - Platform string - DeviceOS string - UserLocale string - UserRole string + CorrelationID string + UserID uuid.UUID + DepartmentID uuid.UUID + AppVersion string + AppID string + AppType string + Platform string + DeviceOS string + UserLocale string + UserRole string } type ctxKeyType struct{} @@ -59,17 +58,16 @@ func NewContext(ctx context.Context, baseFields map[string]interface{}) context. func FromGinContext(ctx context.Context) *ContextInfo { return &ContextInfo{ - CorrelationID: value(ctx, CorrelationIDKey), - UserID: uuidValue(ctx, UserIDKey), - OutletID: uuidValue(ctx, OutletIDKey), - OrganizationID: uuidValue(ctx, OrganizationIDKey), - AppVersion: value(ctx, AppVersionKey), - AppID: value(ctx, AppIDKey), - AppType: value(ctx, AppTypeKey), - Platform: value(ctx, PlatformKey), - DeviceOS: value(ctx, DeviceOSKey), - UserLocale: value(ctx, UserLocaleKey), - UserRole: value(ctx, UserRoleKey), + CorrelationID: value(ctx, CorrelationIDKey), + UserID: uuidValue(ctx, UserIDKey), + DepartmentID: uuidValue(ctx, DepartmentIDKey), + AppVersion: value(ctx, AppVersionKey), + AppID: value(ctx, AppIDKey), + AppType: value(ctx, AppTypeKey), + Platform: value(ctx, PlatformKey), + DeviceOS: value(ctx, DeviceOSKey), + UserLocale: value(ctx, UserLocaleKey), + UserRole: value(ctx, UserRoleKey), } } diff --git a/internal/constants/header.go b/internal/constants/header.go index f2f225b..31d387e 100644 --- a/internal/constants/header.go +++ b/internal/constants/header.go @@ -9,7 +9,7 @@ const ( XAppIDHeader = "x-appid" XPhoneModelHeader = "X-PhoneModel" OrganizationID = "x_organization_id" - OutletID = "x_owner_id" + DepartmentID = "x_department_id" CountryCodeHeader = "country-code" AcceptedLanguageHeader = "accept-language" XUserLocaleHeader = "x-user-locale" diff --git a/internal/contract/common.go b/internal/contract/common.go index e0006c9..b8963dc 100644 --- a/internal/contract/common.go +++ b/internal/contract/common.go @@ -132,6 +132,10 @@ type ListInstitutionsResponse struct { Institutions []InstitutionResponse `json:"institutions"` } +type ListInstitutionsRequest struct { + Search *string `json:"search,omitempty" form:"search"` +} + type DispositionActionResponse struct { ID string `json:"id"` Code string `json:"code"` diff --git a/internal/contract/disposition_route_contract.go b/internal/contract/disposition_route_contract.go index b8eecf2..e3d0b32 100644 --- a/internal/contract/disposition_route_contract.go +++ b/internal/contract/disposition_route_contract.go @@ -6,6 +6,12 @@ import ( "github.com/google/uuid" ) +type DepartmentInfo struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Code string `json:"code,omitempty"` +} + type DispositionRouteResponse struct { ID uuid.UUID `json:"id"` FromDepartmentID uuid.UUID `json:"from_department_id"` @@ -14,6 +20,10 @@ type DispositionRouteResponse struct { AllowedActions map[string]interface{} `json:"allowed_actions,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + + // Department information + FromDepartment DepartmentInfo `json:"from_department"` + ToDepartment DepartmentInfo `json:"to_department"` } type CreateDispositionRouteRequest struct { diff --git a/internal/contract/letter_contract.go b/internal/contract/letter_contract.go index 65c5e8f..66acb12 100644 --- a/internal/contract/letter_contract.go +++ b/internal/contract/letter_contract.go @@ -32,20 +32,20 @@ type IncomingLetterAttachmentResponse struct { } type IncomingLetterResponse struct { - ID uuid.UUID `json:"id"` - LetterNumber string `json:"letter_number"` - ReferenceNumber *string `json:"reference_number,omitempty"` - Subject string `json:"subject"` - Description *string `json:"description,omitempty"` - PriorityID *uuid.UUID `json:"priority_id,omitempty"` - SenderInstitutionID *uuid.UUID `json:"sender_institution_id,omitempty"` - ReceivedDate time.Time `json:"received_date"` - DueDate *time.Time `json:"due_date,omitempty"` - Status string `json:"status"` - CreatedBy uuid.UUID `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Attachments []IncomingLetterAttachmentResponse `json:"attachments"` + ID uuid.UUID `json:"id"` + LetterNumber string `json:"letter_number"` + ReferenceNumber *string `json:"reference_number,omitempty"` + Subject string `json:"subject"` + Description *string `json:"description,omitempty"` + Priority *PriorityResponse `json:"priority,omitempty"` + SenderInstitution *InstitutionResponse `json:"sender_institution,omitempty"` + ReceivedDate time.Time `json:"received_date"` + DueDate *time.Time `json:"due_date,omitempty"` + Status string `json:"status"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Attachments []IncomingLetterAttachmentResponse `json:"attachments"` } type UpdateIncomingLetterRequest struct { @@ -77,6 +77,7 @@ type CreateDispositionActionSelection struct { } type CreateLetterDispositionRequest struct { + FromDepartment uuid.UUID `json:"from_department"` LetterID uuid.UUID `json:"letter_id"` ToDepartmentIDs []uuid.UUID `json:"to_department_ids"` Notes *string `json:"notes,omitempty"` @@ -84,20 +85,64 @@ type CreateLetterDispositionRequest struct { } type DispositionResponse struct { - ID uuid.UUID `json:"id"` - LetterID uuid.UUID `json:"letter_id"` - FromDepartmentID *uuid.UUID `json:"from_department_id,omitempty"` - ToDepartmentID *uuid.UUID `json:"to_department_id,omitempty"` - Notes *string `json:"notes,omitempty"` - Status string `json:"status"` - CreatedBy uuid.UUID `json:"created_by"` - CreatedAt time.Time `json:"created_at"` + ID uuid.UUID `json:"id"` + LetterID uuid.UUID `json:"letter_id"` + DepartmentID *uuid.UUID `json:"department_id,omitempty"` + Notes *string `json:"notes,omitempty"` + ReadAt *time.Time `json:"read_at,omitempty"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ListDispositionsResponse struct { Dispositions []DispositionResponse `json:"dispositions"` } +type EnhancedDispositionResponse struct { + ID uuid.UUID `json:"id"` + LetterID uuid.UUID `json:"letter_id"` + DepartmentID *uuid.UUID `json:"department_id,omitempty"` + Notes *string `json:"notes,omitempty"` + ReadAt *time.Time `json:"read_at,omitempty"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Department DepartmentResponse `json:"department"` + Departments []DispositionDepartmentResponse `json:"departments"` + Actions []DispositionActionSelectionResponse `json:"actions"` + DispositionNotes []DispositionNoteResponse `json:"disposition_notes"` +} + +type DispositionDepartmentResponse struct { + ID uuid.UUID `json:"id"` + DepartmentID uuid.UUID `json:"department_id"` + CreatedAt time.Time `json:"created_at"` + Department *DepartmentResponse `json:"department,omitempty"` +} + +type DispositionActionSelectionResponse struct { + ID uuid.UUID `json:"id"` + ActionID uuid.UUID `json:"action_id"` + Action *DispositionActionResponse `json:"action,omitempty"` + Note *string `json:"note,omitempty"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt time.Time `json:"created_at"` +} + +type DispositionNoteResponse struct { + ID uuid.UUID `json:"id"` + UserID *uuid.UUID `json:"user_id,omitempty"` + Note string `json:"note"` + CreatedAt time.Time `json:"created_at"` + User *UserResponse `json:"user,omitempty"` +} + +type ListEnhancedDispositionsResponse struct { + Dispositions []EnhancedDispositionResponse `json:"dispositions"` + Discussions []LetterDiscussionResponse `json:"discussions"` +} + type CreateLetterDiscussionRequest struct { ParentID *uuid.UUID `json:"parent_id,omitempty"` Message string `json:"message"` @@ -119,4 +164,10 @@ type LetterDiscussionResponse struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` EditedAt *time.Time `json:"edited_at,omitempty"` + + // Preloaded user profile who created the discussion + User *UserResponse `json:"user,omitempty"` + + // Preloaded user profiles for mentions + MentionedUsers []UserResponse `json:"mentioned_users,omitempty"` } diff --git a/internal/contract/user_contract.go b/internal/contract/user_contract.go index 970c173..2ebb6de 100644 --- a/internal/contract/user_contract.go +++ b/internal/contract/user_contract.go @@ -48,14 +48,15 @@ type LoginResponse struct { } type UserResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Roles []RoleResponse `json:"roles,omitempty"` - Profile *UserProfileResponse `json:"profile,omitempty"` + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Roles []RoleResponse `json:"roles,omitempty"` + DepartmentResponse []DepartmentResponse `json:"department_response"` + Profile *UserProfileResponse `json:"profile,omitempty"` } type ListUsersRequest struct { @@ -128,3 +129,15 @@ type TitleResponse struct { type ListTitlesResponse struct { Titles []TitleResponse `json:"titles"` } + +// MentionUsersRequest represents the request for getting users for mention purposes +type MentionUsersRequest struct { + Search *string `json:"search,omitempty" form:"search"` // Optional search term for username + Limit *int `json:"limit,omitempty" form:"limit"` // Optional limit, defaults to 50, max 100 +} + +// MentionUsersResponse represents the response for getting users for mention purposes +type MentionUsersResponse struct { + Users []UserResponse `json:"users"` + Count int `json:"count"` +} diff --git a/internal/entities/disposition_route.go b/internal/entities/disposition_route.go index 1c8bfc7..715477f 100644 --- a/internal/entities/disposition_route.go +++ b/internal/entities/disposition_route.go @@ -14,6 +14,10 @@ type DispositionRoute struct { AllowedActions JSONB `gorm:"type:jsonb" json:"allowed_actions,omitempty"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + // Relationships + FromDepartment Department `gorm:"foreignKey:FromDepartmentID;references:ID" json:"from_department,omitempty"` + ToDepartment Department `gorm:"foreignKey:ToDepartmentID;references:ID" json:"to_department,omitempty"` } func (DispositionRoute) TableName() string { return "disposition_routes" } diff --git a/internal/entities/letter_discussion.go b/internal/entities/letter_discussion.go index 6e27112..e6cf36a 100644 --- a/internal/entities/letter_discussion.go +++ b/internal/entities/letter_discussion.go @@ -16,6 +16,9 @@ type LetterDiscussion struct { CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` EditedAt *time.Time `json:"edited_at,omitempty"` + + // Relationships + User *User `gorm:"foreignKey:UserID;references:ID" json:"user,omitempty"` } func (LetterDiscussion) TableName() string { return "letter_incoming_discussions" } diff --git a/internal/entities/letter_disposition.go b/internal/entities/letter_disposition.go index 002bf3d..9ef16f1 100644 --- a/internal/entities/letter_disposition.go +++ b/internal/entities/letter_disposition.go @@ -6,32 +6,36 @@ import ( "github.com/google/uuid" ) -type LetterDispositionStatus string - -const ( - DispositionPending LetterDispositionStatus = "pending" - DispositionRead LetterDispositionStatus = "read" - DispositionRejected LetterDispositionStatus = "rejected" - DispositionCompleted LetterDispositionStatus = "completed" -) - -type LetterDisposition struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - FromUserID *uuid.UUID `json:"from_user_id,omitempty"` - FromDepartmentID *uuid.UUID `json:"from_department_id,omitempty"` - ToUserID *uuid.UUID `json:"to_user_id,omitempty"` - ToDepartmentID *uuid.UUID `json:"to_department_id,omitempty"` - Notes *string `json:"notes,omitempty"` - Status LetterDispositionStatus `gorm:"not null;default:'pending'" json:"status"` - CreatedBy uuid.UUID `gorm:"not null" json:"created_by"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - ReadAt *time.Time `json:"read_at,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +type LetterIncomingDisposition struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` + DepartmentID *uuid.UUID `json:"department_id,omitempty"` + Notes *string `json:"notes,omitempty"` + ReadAt *time.Time `json:"read_at,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + CreatedBy uuid.UUID `gorm:"not null" json:"created_by"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + Department Department `gorm:"foreignKey:DepartmentID;references:ID" json:"department,omitempty"` + Departments []LetterIncomingDispositionDepartment `gorm:"foreignKey:LetterIncomingDispositionID;references:ID" json:"departments,omitempty"` + ActionSelections []LetterDispositionActionSelection `gorm:"foreignKey:DispositionID;references:ID" json:"action_selections,omitempty"` + DispositionNotes []DispositionNote `gorm:"foreignKey:DispositionID;references:ID" json:"disposition_notes,omitempty"` } -func (LetterDisposition) TableName() string { return "letter_dispositions" } +func (LetterIncomingDisposition) TableName() string { return "letter_incoming_dispositions" } + +type LetterIncomingDispositionDepartment struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + LetterIncomingDispositionID uuid.UUID `gorm:"type:uuid;not null" json:"letter_incoming_disposition_id"` + DepartmentID uuid.UUID `gorm:"type:uuid;not null" json:"department_id"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + + // Relationships + Department *Department `gorm:"foreignKey:DepartmentID;references:ID" json:"department,omitempty"` +} + +func (LetterIncomingDispositionDepartment) TableName() string { + return "letter_incoming_dispositions_department" +} type DispositionNote struct { ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` @@ -39,6 +43,9 @@ type DispositionNote struct { UserID *uuid.UUID `json:"user_id,omitempty"` Note string `gorm:"not null" json:"note"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + + // Relationships + User *User `gorm:"foreignKey:UserID;references:ID" json:"user,omitempty"` } func (DispositionNote) TableName() string { return "disposition_notes" } @@ -50,6 +57,9 @@ type LetterDispositionActionSelection struct { Note *string `json:"note,omitempty"` CreatedBy uuid.UUID `gorm:"not null" json:"created_by"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + + // Relationships + Action *DispositionAction `gorm:"foreignKey:ActionID;references:ID" json:"action,omitempty"` } func (LetterDispositionActionSelection) TableName() string { return "letter_disposition_actions" } diff --git a/internal/entities/user.go b/internal/entities/user.go index ebb7ff4..4247b01 100644 --- a/internal/entities/user.go +++ b/internal/entities/user.go @@ -48,6 +48,7 @@ type User struct { CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Profile *UserProfile `gorm:"foreignKey:UserID;references:ID" json:"profile,omitempty"` + Departments []Department `gorm:"many2many:user_department;foreignKey:ID;joinForeignKey:user_id;References:ID;joinReferences:department_id" json:"departments,omitempty"` } func (u *User) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/handler/disposition_route_handler.go b/internal/handler/disposition_route_handler.go index 1305258..1b7985b 100644 --- a/internal/handler/disposition_route_handler.go +++ b/internal/handler/disposition_route_handler.go @@ -2,6 +2,7 @@ package handler import ( "context" + "eslogad-be/internal/appcontext" "eslogad-be/internal/contract" @@ -71,12 +72,9 @@ func (h *DispositionRouteHandler) Get(c *gin.Context) { } func (h *DispositionRouteHandler) ListByFromDept(c *gin.Context) { - fromID, err := uuid.Parse(c.Param("from_department_id")) - if err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid from_department_id", Code: 400}) - return - } - resp, err := h.svc.ListByFromDept(c.Request.Context(), fromID) + appCtx := appcontext.FromGinContext(c.Request.Context()) + + resp, err := h.svc.ListByFromDept(c.Request.Context(), appCtx.DepartmentID) if err != nil { c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) return diff --git a/internal/handler/letter_handler.go b/internal/handler/letter_handler.go index 05635b7..b739759 100644 --- a/internal/handler/letter_handler.go +++ b/internal/handler/letter_handler.go @@ -2,6 +2,7 @@ package handler import ( "context" + "eslogad-be/internal/appcontext" "net/http" "strconv" @@ -19,7 +20,7 @@ type LetterService interface { SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) - ListDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListDispositionsResponse, error) + GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) @@ -112,11 +113,13 @@ func (h *LetterHandler) DeleteIncomingLetter(c *gin.Context) { } func (h *LetterHandler) CreateDispositions(c *gin.Context) { + appCtx := appcontext.FromGinContext(c.Request.Context()) var req contract.CreateLetterDispositionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400}) return } + req.FromDepartment = appCtx.DepartmentID resp, err := h.svc.CreateDispositions(c.Request.Context(), &req) if err != nil { c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) @@ -125,13 +128,13 @@ func (h *LetterHandler) CreateDispositions(c *gin.Context) { c.JSON(201, contract.BuildSuccessResponse(resp)) } -func (h *LetterHandler) ListDispositionsByLetter(c *gin.Context) { +func (h *LetterHandler) GetEnhancedDispositionsByLetter(c *gin.Context) { letterID, err := uuid.Parse(c.Param("letter_id")) if err != nil { c.JSON(400, &contract.ErrorResponse{Error: "invalid letter_id", Code: 400}) return } - resp, err := h.svc.ListDispositionsByLetter(c.Request.Context(), letterID) + resp, err := h.svc.GetEnhancedDispositionsByLetter(c.Request.Context(), letterID) if err != nil { c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) return diff --git a/internal/handler/master_handler.go b/internal/handler/master_handler.go index 3c5f1e8..04b08b7 100644 --- a/internal/handler/master_handler.go +++ b/internal/handler/master_handler.go @@ -24,7 +24,7 @@ type MasterService interface { CreateInstitution(ctx context.Context, req *contract.CreateInstitutionRequest) (*contract.InstitutionResponse, error) UpdateInstitution(ctx context.Context, id uuid.UUID, req *contract.UpdateInstitutionRequest) (*contract.InstitutionResponse, error) DeleteInstitution(ctx context.Context, id uuid.UUID) error - ListInstitutions(ctx context.Context) (*contract.ListInstitutionsResponse, error) + ListInstitutions(ctx context.Context, req *contract.ListInstitutionsRequest) (*contract.ListInstitutionsResponse, error) CreateDispositionAction(ctx context.Context, req *contract.CreateDispositionActionRequest) (*contract.DispositionActionResponse, error) UpdateDispositionAction(ctx context.Context, id uuid.UUID, req *contract.UpdateDispositionActionRequest) (*contract.DispositionActionResponse, error) @@ -190,7 +190,13 @@ func (h *MasterHandler) DeleteInstitution(c *gin.Context) { } func (h *MasterHandler) ListInstitutions(c *gin.Context) { - resp, err := h.svc.ListInstitutions(c.Request.Context()) + var req contract.ListInstitutionsRequest + + if search := c.Query("search"); search != "" { + req.Search = &search + } + + resp, err := h.svc.ListInstitutions(c.Request.Context(), &req) if err != nil { c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) return diff --git a/internal/handler/master_handler_test.go b/internal/handler/master_handler_test.go new file mode 100644 index 0000000..9f0d3f4 --- /dev/null +++ b/internal/handler/master_handler_test.go @@ -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) +} diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index f200acc..e6e8815 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -285,12 +285,48 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) { } func (h *UserHandler) ListTitles(c *gin.Context) { - resp, err := h.userService.ListTitles(c.Request.Context()) + titles, err := h.userService.ListTitles(c.Request.Context()) if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::ListTitles -> Failed to get titles from service") h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) return } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) + + logger.FromContext(c).Infof("UserHandler::ListTitles -> Successfully retrieved titles = %+v", titles) + c.JSON(http.StatusOK, titles) +} + +func (h *UserHandler) GetActiveUsersForMention(c *gin.Context) { + search := c.Query("search") + limitStr := c.DefaultQuery("limit", "50") + + limit, err := strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + + var searchPtr *string + if search != "" { + searchPtr = &search + } + + users, err := h.userService.GetActiveUsersForMention(c.Request.Context(), searchPtr, limit) + if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::GetActiveUsersForMention -> Failed to get active users from service") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + response := contract.MentionUsersResponse{ + Users: users, + Count: len(users), + } + + logger.FromContext(c).Infof("UserHandler::GetActiveUsersForMention -> Successfully retrieved %d active users", len(users)) + c.JSON(http.StatusOK, response) } func (h *UserHandler) sendErrorResponse(c *gin.Context, message string, statusCode int) { diff --git a/internal/handler/user_service.go b/internal/handler/user_service.go index d420fec..9734879 100644 --- a/internal/handler/user_service.go +++ b/internal/handler/user_service.go @@ -20,4 +20,7 @@ type UserService interface { UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) ListTitles(ctx context.Context) (*contract.ListTitlesResponse, error) + + // Get active users for mention purposes + GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) } diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go index 8161499..0eb2f34 100644 --- a/internal/middleware/auth_middleware.go +++ b/internal/middleware/auth_middleware.go @@ -41,6 +41,12 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc { } setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String()) + if len(userResponse.DepartmentResponse) > 0 { + departmentID := userResponse.DepartmentResponse[0].ID.String() + setKeyInContext(c, appcontext.DepartmentIDKey, departmentID) + } else { + setKeyInContext(c, appcontext.DepartmentIDKey, "") + } if roles, perms, err := m.authService.ExtractAccess(token); err == nil { c.Set("user_roles", roles) diff --git a/internal/middleware/context.go b/internal/middleware/context.go index 60ed530..891e4ac 100644 --- a/internal/middleware/context.go +++ b/internal/middleware/context.go @@ -13,7 +13,7 @@ func PopulateContext() gin.HandlerFunc { setKeyInContext(c, appcontext.AppVersionKey, getAppVersion(c)) setKeyInContext(c, appcontext.AppTypeKey, getAppType(c)) setKeyInContext(c, appcontext.OrganizationIDKey, getOrganizationID(c)) - setKeyInContext(c, appcontext.OutletIDKey, getOutletID(c)) + setKeyInContext(c, appcontext.DepartmentIDKey, getDepartmentID(c)) setKeyInContext(c, appcontext.DeviceOSKey, getDeviceOS(c)) setKeyInContext(c, appcontext.PlatformKey, getDevicePlatform(c)) setKeyInContext(c, appcontext.UserLocaleKey, getUserLocale(c)) @@ -37,8 +37,8 @@ func getOrganizationID(c *gin.Context) string { return c.GetHeader(constants.OrganizationID) } -func getOutletID(c *gin.Context) string { - return c.GetHeader(constants.OutletID) +func getDepartmentID(c *gin.Context) string { + return c.GetHeader(constants.DepartmentID) } func getDeviceOS(c *gin.Context) string { diff --git a/internal/processor/letter_processor.go b/internal/processor/letter_processor.go index b128ab9..bafdf3a 100644 --- a/internal/processor/letter_processor.go +++ b/internal/processor/letter_processor.go @@ -15,25 +15,26 @@ import ( ) type LetterProcessorImpl struct { - letterRepo *repository.LetterIncomingRepository - attachRepo *repository.LetterIncomingAttachmentRepository - txManager *repository.TxManager - activity *ActivityLogProcessorImpl - // new repos for dispositions - dispositionRepo *repository.LetterDispositionRepository + letterRepo *repository.LetterIncomingRepository + attachRepo *repository.LetterIncomingAttachmentRepository + txManager *repository.TxManager + activity *ActivityLogProcessorImpl + dispositionRepo *repository.LetterIncomingDispositionRepository + dispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository dispositionActionSelRepo *repository.LetterDispositionActionSelectionRepository dispositionNoteRepo *repository.DispositionNoteRepository - // discussion repo - discussionRepo *repository.LetterDiscussionRepository - // settings and recipients - settingRepo *repository.AppSettingRepository - recipientRepo *repository.LetterIncomingRecipientRepository - departmentRepo *repository.DepartmentRepository - userDeptRepo *repository.UserDepartmentRepository + discussionRepo *repository.LetterDiscussionRepository + settingRepo *repository.AppSettingRepository + recipientRepo *repository.LetterIncomingRecipientRepository + departmentRepo *repository.DepartmentRepository + userDeptRepo *repository.UserDepartmentRepository + priorityRepo *repository.PriorityRepository + institutionRepo *repository.InstitutionRepository + dispActionRepo *repository.DispositionActionRepository } -func NewLetterProcessor(letterRepo *repository.LetterIncomingRepository, attachRepo *repository.LetterIncomingAttachmentRepository, txManager *repository.TxManager, activity *ActivityLogProcessorImpl, dispRepo *repository.LetterDispositionRepository, dispSelRepo *repository.LetterDispositionActionSelectionRepository, noteRepo *repository.DispositionNoteRepository, discussionRepo *repository.LetterDiscussionRepository, settingRepo *repository.AppSettingRepository, recipientRepo *repository.LetterIncomingRecipientRepository, departmentRepo *repository.DepartmentRepository, userDeptRepo *repository.UserDepartmentRepository) *LetterProcessorImpl { - return &LetterProcessorImpl{letterRepo: letterRepo, attachRepo: attachRepo, txManager: txManager, activity: activity, dispositionRepo: dispRepo, dispositionActionSelRepo: dispSelRepo, dispositionNoteRepo: noteRepo, discussionRepo: discussionRepo, settingRepo: settingRepo, recipientRepo: recipientRepo, departmentRepo: departmentRepo, userDeptRepo: userDeptRepo} +func NewLetterProcessor(letterRepo *repository.LetterIncomingRepository, attachRepo *repository.LetterIncomingAttachmentRepository, txManager *repository.TxManager, activity *ActivityLogProcessorImpl, dispRepo *repository.LetterIncomingDispositionRepository, dispDeptRepo *repository.LetterIncomingDispositionDepartmentRepository, dispSelRepo *repository.LetterDispositionActionSelectionRepository, noteRepo *repository.DispositionNoteRepository, discussionRepo *repository.LetterDiscussionRepository, settingRepo *repository.AppSettingRepository, recipientRepo *repository.LetterIncomingRecipientRepository, departmentRepo *repository.DepartmentRepository, userDeptRepo *repository.UserDepartmentRepository, priorityRepo *repository.PriorityRepository, institutionRepo *repository.InstitutionRepository, dispActionRepo *repository.DispositionActionRepository) *LetterProcessorImpl { + return &LetterProcessorImpl{letterRepo: letterRepo, attachRepo: attachRepo, txManager: txManager, activity: activity, dispositionRepo: dispRepo, dispositionDeptRepo: dispDeptRepo, dispositionActionSelRepo: dispSelRepo, dispositionNoteRepo: noteRepo, discussionRepo: discussionRepo, settingRepo: settingRepo, recipientRepo: recipientRepo, departmentRepo: departmentRepo, userDeptRepo: userDeptRepo, priorityRepo: priorityRepo, institutionRepo: institutionRepo, dispActionRepo: dispActionRepo} } func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) { @@ -85,7 +86,6 @@ func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *con } } - // resolve department codes to ids using repository depIDs := make([]uuid.UUID, 0, len(defaultDeptCodes)) for _, code := range defaultDeptCodes { dep, err := p.departmentRepo.GetByCode(txCtx, code) @@ -94,20 +94,19 @@ func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *con } depIDs = append(depIDs, dep.ID) } - // query user memberships for all departments at once + userMemberships, _ := p.userDeptRepo.ListActiveByDepartmentIDs(txCtx, depIDs) - // build recipients: one department recipient per department + one user recipient per membership - recipients := make([]entities.LetterIncomingRecipient, 0, len(depIDs)+len(userMemberships)) - // department recipients - for _, depID := range depIDs { - id := depID - recipients = append(recipients, entities.LetterIncomingRecipient{LetterID: entity.ID, RecipientDepartmentID: &id, Status: entities.RecipientStatusNew}) - } - // user recipients + var recipients []entities.LetterIncomingRecipient + + mapsUsers := map[string]bool{} for _, row := range userMemberships { uid := row.UserID - recipients = append(recipients, entities.LetterIncomingRecipient{LetterID: entity.ID, RecipientUserID: &uid, Status: entities.RecipientStatusNew}) + if _, ok := mapsUsers[uid.String()]; !ok { + recipients = append(recipients, entities.LetterIncomingRecipient{LetterID: entity.ID, RecipientUserID: &uid, RecipientDepartmentID: &row.DepartmentID, Status: entities.RecipientStatusNew}) + } + mapsUsers[uid.String()] = true } + if len(recipients) > 0 { if err := p.recipientRepo.CreateBulk(txCtx, recipients); err != nil { return err @@ -141,9 +140,26 @@ func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *con } savedAttachments, _ := p.attachRepo.ListByLetter(txCtx, entity.ID) - result = transformer.LetterEntityToContract(entity, savedAttachments) + var pr *entities.Priority + if entity.PriorityID != nil { + if p.priorityRepo != nil { + if got, err := p.priorityRepo.Get(txCtx, *entity.PriorityID); err == nil { + pr = got + } + } + } + var inst *entities.Institution + if entity.SenderInstitutionID != nil { + if p.institutionRepo != nil { + if got, err := p.institutionRepo.Get(txCtx, *entity.SenderInstitutionID); err == nil { + inst = got + } + } + } + result = transformer.LetterEntityToContract(entity, savedAttachments, pr, inst) return nil }) + if err != nil { return nil, err } @@ -156,7 +172,19 @@ func (p *LetterProcessorImpl) GetIncomingLetterByID(ctx context.Context, id uuid return nil, err } atts, _ := p.attachRepo.ListByLetter(ctx, id) - return transformer.LetterEntityToContract(entity, atts), nil + var pr *entities.Priority + if entity.PriorityID != nil && p.priorityRepo != nil { + if got, err := p.priorityRepo.Get(ctx, *entity.PriorityID); err == nil { + pr = got + } + } + var inst *entities.Institution + if entity.SenderInstitutionID != nil && p.institutionRepo != nil { + if got, err := p.institutionRepo.Get(ctx, *entity.SenderInstitutionID); err == nil { + inst = got + } + } + return transformer.LetterEntityToContract(entity, atts, pr, inst), nil } func (p *LetterProcessorImpl) ListIncomingLetters(ctx context.Context, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error) { @@ -175,7 +203,19 @@ func (p *LetterProcessorImpl) ListIncomingLetters(ctx context.Context, req *cont respList := make([]contract.IncomingLetterResponse, 0, len(list)) for _, e := range list { atts, _ := p.attachRepo.ListByLetter(ctx, e.ID) - resp := transformer.LetterEntityToContract(&e, atts) + var pr *entities.Priority + if e.PriorityID != nil && p.priorityRepo != nil { + if got, err := p.priorityRepo.Get(ctx, *e.PriorityID); err == nil { + pr = got + } + } + var inst *entities.Institution + if e.SenderInstitutionID != nil && p.institutionRepo != nil { + if got, err := p.institutionRepo.Get(ctx, *e.SenderInstitutionID); err == nil { + inst = got + } + } + resp := transformer.LetterEntityToContract(&e, atts, pr, inst) respList = append(respList, *resp) } return &contract.ListIncomingLettersResponse{Letters: respList, Pagination: transformer.CreatePaginationResponse(int(total), page, limit)}, nil @@ -225,7 +265,19 @@ func (p *LetterProcessorImpl) UpdateIncomingLetter(ctx context.Context, id uuid. } } atts, _ := p.attachRepo.ListByLetter(txCtx, id) - out = transformer.LetterEntityToContract(entity, atts) + var pr *entities.Priority + if entity.PriorityID != nil && p.priorityRepo != nil { + if got, err := p.priorityRepo.Get(txCtx, *entity.PriorityID); err == nil { + pr = got + } + } + var inst *entities.Institution + if entity.SenderInstitutionID != nil && p.institutionRepo != nil { + if got, err := p.institutionRepo.Get(txCtx, *entity.SenderInstitutionID); err == nil { + inst = got + } + } + out = transformer.LetterEntityToContract(entity, atts, pr, inst) return nil }) if err != nil { @@ -254,48 +306,53 @@ func (p *LetterProcessorImpl) CreateDispositions(ctx context.Context, req *contr var out *contract.ListDispositionsResponse err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { userID := appcontext.FromGinContext(txCtx).UserID - created := make([]entities.LetterDisposition, 0, len(req.ToDepartmentIDs)) + + disp := entities.LetterIncomingDisposition{ + LetterID: req.LetterID, + DepartmentID: &req.FromDepartment, + Notes: req.Notes, + CreatedBy: userID, + } + if err := p.dispositionRepo.Create(txCtx, &disp); err != nil { + return err + } + + var dispDepartments []entities.LetterIncomingDispositionDepartment for _, toDept := range req.ToDepartmentIDs { - disp := entities.LetterDisposition{ - LetterID: req.LetterID, - FromDepartmentID: nil, - ToDepartmentID: &toDept, - Notes: req.Notes, - Status: entities.DispositionPending, - CreatedBy: userID, + dispDepartments = append(dispDepartments, entities.LetterIncomingDispositionDepartment{ + LetterIncomingDispositionID: disp.ID, + DepartmentID: toDept, + }) + } + + if err := p.dispositionDeptRepo.CreateBulk(txCtx, dispDepartments); err != nil { + return err + } + + if len(req.SelectedActions) > 0 { + selections := make([]entities.LetterDispositionActionSelection, 0, len(req.SelectedActions)) + for _, sel := range req.SelectedActions { + selections = append(selections, entities.LetterDispositionActionSelection{ + DispositionID: disp.ID, + ActionID: sel.ActionID, + Note: sel.Note, + CreatedBy: userID, + }) } - if err := p.dispositionRepo.Create(txCtx, &disp); err != nil { + if err := p.dispositionActionSelRepo.CreateBulk(txCtx, selections); err != nil { return err } - created = append(created, disp) - - if len(req.SelectedActions) > 0 { - selections := make([]entities.LetterDispositionActionSelection, 0, len(req.SelectedActions)) - for _, sel := range req.SelectedActions { - selections = append(selections, entities.LetterDispositionActionSelection{ - DispositionID: disp.ID, - ActionID: sel.ActionID, - Note: sel.Note, - CreatedBy: userID, - }) - } - if err := p.dispositionActionSelRepo.CreateBulk(txCtx, selections); err != nil { - return err - } - } - - if p.activity != nil { - action := "disposition.created" - for _, d := range created { - ctxMap := map[string]interface{}{"to_department_id": d.ToDepartmentID} - if err := p.activity.Log(txCtx, req.LetterID, action, &userID, nil, nil, &d.ID, nil, nil, ctxMap); err != nil { - return err - } - } - } } - out = &contract.ListDispositionsResponse{Dispositions: transformer.DispositionsToContract(created)} + if p.activity != nil { + action := "disposition.created" + ctxMap := map[string]interface{}{"to_department_id": dispDepartments} + if err := p.activity.Log(txCtx, req.LetterID, action, &userID, nil, nil, &disp.ID, nil, nil, ctxMap); err != nil { + return err + } + } + + out = &contract.ListDispositionsResponse{Dispositions: []contract.DispositionResponse{transformer.DispoToContract(disp)}} return nil }) if err != nil { @@ -312,6 +369,64 @@ func (p *LetterProcessorImpl) ListDispositionsByLetter(ctx context.Context, lett return &contract.ListDispositionsResponse{Dispositions: transformer.DispositionsToContract(list)}, nil } +func (p *LetterProcessorImpl) GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) { + // Get dispositions with all related data preloaded in a single query + dispositions, err := p.dispositionRepo.ListByLetter(ctx, letterID) + if err != nil { + return nil, err + } + + // Get discussions with preloaded user profiles + discussions, err := p.discussionRepo.ListByLetter(ctx, letterID) + if err != nil { + return nil, err + } + + // Extract all mentioned user IDs from discussions for efficient batch fetching + var mentionedUserIDs []uuid.UUID + mentionedUserIDsMap := make(map[uuid.UUID]bool) + + for _, discussion := range discussions { + if discussion.Mentions != nil { + mentions := map[string]interface{}(discussion.Mentions) + if userIDs, ok := mentions["user_ids"]; ok { + if userIDList, ok := userIDs.([]interface{}); ok { + for _, userID := range userIDList { + if userIDStr, ok := userID.(string); ok { + if userUUID, err := uuid.Parse(userIDStr); err == nil { + if !mentionedUserIDsMap[userUUID] { + mentionedUserIDsMap[userUUID] = true + mentionedUserIDs = append(mentionedUserIDs, userUUID) + } + } + } + } + } + } + } + } + + // Fetch all mentioned users in a single batch query + var mentionedUsers []entities.User + if len(mentionedUserIDs) > 0 { + mentionedUsers, err = p.discussionRepo.GetUsersByIDs(ctx, mentionedUserIDs) + if err != nil { + return nil, err + } + } + + // Transform dispositions + enhancedDispositions := transformer.EnhancedDispositionsWithPreloadedDataToContract(dispositions) + + // Transform discussions with mentioned users + enhancedDiscussions := transformer.DiscussionsWithPreloadedDataToContract(discussions, mentionedUsers) + + return &contract.ListEnhancedDispositionsResponse{ + Dispositions: enhancedDispositions, + Discussions: enhancedDiscussions, + }, nil +} + func (p *LetterProcessorImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) { var out *contract.LetterDiscussionResponse err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { @@ -320,7 +435,7 @@ func (p *LetterProcessorImpl) CreateDiscussion(ctx context.Context, letterID uui if req.Mentions != nil { mentions = entities.JSONB(req.Mentions) } - disc := &entities.LetterDiscussion{LetterID: letterID, ParentID: req.ParentID, UserID: userID, Message: req.Message, Mentions: mentions} + disc := &entities.LetterDiscussion{ID: uuid.New(), LetterID: letterID, ParentID: req.ParentID, UserID: userID, Message: req.Message, Mentions: mentions} if err := p.discussionRepo.Create(txCtx, disc); err != nil { return err } diff --git a/internal/processor/user_processor.go b/internal/processor/user_processor.go index a241081..c4a103f 100644 --- a/internal/processor/user_processor.go +++ b/internal/processor/user_processor.go @@ -112,9 +112,11 @@ func (p *UserProcessorImpl) GetUserByID(ctx context.Context, id uuid.UUID) (*con } resp := transformer.EntityToContract(user) if resp != nil { + // Roles are loaded separately since they're not preloaded if roles, err := p.userRepo.GetRolesByUserID(ctx, resp.ID); err == nil { resp.Roles = transformer.RolesToContract(roles) } + // Departments are now preloaded, so they're already in the response } return resp, nil } @@ -125,6 +127,7 @@ func (p *UserProcessorImpl) GetUserByEmail(ctx context.Context, email string) (* return nil, fmt.Errorf("user not found: %w", err) } + // Departments are now preloaded, so they're already in the response return transformer.EntityToContract(user), nil } @@ -149,6 +152,7 @@ func (p *UserProcessorImpl) ListUsersWithFilters(ctx context.Context, req *contr for i := range responses { userIDs = append(userIDs, responses[i].ID) } + // Roles are loaded separately since they're not preloaded rolesMap, err := p.userRepo.GetRolesByUserIDs(ctx, userIDs) if err == nil { for i := range responses { @@ -157,6 +161,7 @@ func (p *UserProcessorImpl) ListUsersWithFilters(ctx context.Context, req *contr } } } + // Departments are now preloaded, so they're already in the responses return responses, int(totalCount), nil } @@ -272,3 +277,38 @@ func (p *UserProcessorImpl) UpdateUserProfile(ctx context.Context, userID uuid.U } return transformer.ProfileEntityToContract(entity), nil } + +// GetActiveUsersForMention retrieves active users for mention purposes with optional username search +func (p *UserProcessorImpl) GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) { + if limit <= 0 { + limit = 50 // Default limit for mention suggestions + } + if limit > 100 { + limit = 100 // Max limit for mention suggestions + } + + // Set isActive to true to only get active users + isActive := true + users, _, err := p.userRepo.ListWithFilters(ctx, search, nil, &isActive, limit, 0) + if err != nil { + return nil, fmt.Errorf("failed to get active users: %w", err) + } + + responses := transformer.EntitiesToContracts(users) + userIDs := make([]uuid.UUID, 0, len(responses)) + for i := range responses { + userIDs = append(userIDs, responses[i].ID) + } + + // Load roles for the users + rolesMap, err := p.userRepo.GetRolesByUserIDs(ctx, userIDs) + if err == nil { + for i := range responses { + if roles, ok := rolesMap[responses[i].ID]; ok { + responses[i].Roles = transformer.RolesToContract(roles) + } + } + } + + return responses, nil +} diff --git a/internal/processor/user_processor_test.go b/internal/processor/user_processor_test.go new file mode 100644 index 0000000..bc9e9ca --- /dev/null +++ b/internal/processor/user_processor_test.go @@ -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 +} diff --git a/internal/repository/disposition_route_repository.go b/internal/repository/disposition_route_repository.go index 2a59980..b65c61d 100644 --- a/internal/repository/disposition_route_repository.go +++ b/internal/repository/disposition_route_repository.go @@ -26,7 +26,10 @@ func (r *DispositionRouteRepository) Update(ctx context.Context, e *entities.Dis func (r *DispositionRouteRepository) Get(ctx context.Context, id uuid.UUID) (*entities.DispositionRoute, error) { db := DBFromContext(ctx, r.db) var e entities.DispositionRoute - if err := db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil { + if err := db.WithContext(ctx). + Preload("FromDepartment"). + Preload("ToDepartment"). + First(&e, "id = ?", id).Error; err != nil { return nil, err } return &e, nil @@ -34,11 +37,15 @@ func (r *DispositionRouteRepository) Get(ctx context.Context, id uuid.UUID) (*en func (r *DispositionRouteRepository) ListByFromDept(ctx context.Context, fromDept uuid.UUID) ([]entities.DispositionRoute, error) { db := DBFromContext(ctx, r.db) var list []entities.DispositionRoute - if err := db.WithContext(ctx).Where("from_department_id = ?", fromDept).Order("to_department_id").Find(&list).Error; err != nil { + if err := db.WithContext(ctx).Where("from_department_id = ?", fromDept). + Preload("FromDepartment"). + Preload("ToDepartment"). + Order("to_department_id").Find(&list).Error; err != nil { return nil, err } return list, nil } + func (r *DispositionRouteRepository) SetActive(ctx context.Context, id uuid.UUID, isActive bool) error { db := DBFromContext(ctx, r.db) return db.WithContext(ctx).Model(&entities.DispositionRoute{}).Where("id = ?", id).Update("is_active", isActive).Error diff --git a/internal/repository/letter_repository.go b/internal/repository/letter_repository.go index 6fedabc..9225419 100644 --- a/internal/repository/letter_repository.go +++ b/internal/repository/letter_repository.go @@ -104,19 +104,56 @@ func (r *LetterIncomingActivityLogRepository) ListByLetter(ctx context.Context, return list, nil } -type LetterDispositionRepository struct{ db *gorm.DB } +type LetterIncomingDispositionRepository struct{ db *gorm.DB } -func NewLetterDispositionRepository(db *gorm.DB) *LetterDispositionRepository { - return &LetterDispositionRepository{db: db} +func NewLetterIncomingDispositionRepository(db *gorm.DB) *LetterIncomingDispositionRepository { + return &LetterIncomingDispositionRepository{db: db} } -func (r *LetterDispositionRepository) Create(ctx context.Context, e *entities.LetterDisposition) error { +func (r *LetterIncomingDispositionRepository) Create(ctx context.Context, e *entities.LetterIncomingDisposition) error { db := DBFromContext(ctx, r.db) return db.WithContext(ctx).Create(e).Error } -func (r *LetterDispositionRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterDisposition, error) { +func (r *LetterIncomingDispositionRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingDisposition, error) { db := DBFromContext(ctx, r.db) - var list []entities.LetterDisposition - if err := db.WithContext(ctx).Where("letter_id = ?", letterID).Order("created_at ASC").Find(&list).Error; err != nil { + var list []entities.LetterIncomingDisposition + if err := db.WithContext(ctx). + Where("letter_id = ?", letterID). + Preload("Department"). + Preload("Departments.Department"). + Preload("ActionSelections.Action"). + Preload("DispositionNotes.User"). + Order("created_at ASC"). + Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +type LetterIncomingDispositionDepartmentRepository struct{ db *gorm.DB } + +func NewLetterIncomingDispositionDepartmentRepository(db *gorm.DB) *LetterIncomingDispositionDepartmentRepository { + return &LetterIncomingDispositionDepartmentRepository{db: db} +} +func (r *LetterIncomingDispositionDepartmentRepository) CreateBulk(ctx context.Context, list []entities.LetterIncomingDispositionDepartment) error { + db := DBFromContext(ctx, r.db) + return db.WithContext(ctx).Create(&list).Error +} +func (r *LetterIncomingDispositionDepartmentRepository) ListByDisposition(ctx context.Context, dispositionID uuid.UUID) ([]entities.LetterIncomingDispositionDepartment, error) { + db := DBFromContext(ctx, r.db) + var list []entities.LetterIncomingDispositionDepartment + if err := db.WithContext(ctx).Where("letter_incoming_disposition_id = ?", dispositionID).Order("created_at ASC").Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func (r *LetterIncomingDispositionDepartmentRepository) ListByDispositions(ctx context.Context, dispositionIDs []uuid.UUID) ([]entities.LetterIncomingDispositionDepartment, error) { + db := DBFromContext(ctx, r.db) + var list []entities.LetterIncomingDispositionDepartment + if len(dispositionIDs) == 0 { + return list, nil + } + if err := db.WithContext(ctx).Where("letter_incoming_disposition_id IN ?", dispositionIDs).Order("letter_incoming_disposition_id, created_at ASC").Find(&list).Error; err != nil { return nil, err } return list, nil @@ -132,6 +169,27 @@ func (r *DispositionNoteRepository) Create(ctx context.Context, e *entities.Disp return db.WithContext(ctx).Create(e).Error } +func (r *DispositionNoteRepository) ListByDisposition(ctx context.Context, dispositionID uuid.UUID) ([]entities.DispositionNote, error) { + db := DBFromContext(ctx, r.db) + var list []entities.DispositionNote + if err := db.WithContext(ctx).Where("disposition_id = ?", dispositionID).Order("created_at ASC").Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func (r *DispositionNoteRepository) ListByDispositions(ctx context.Context, dispositionIDs []uuid.UUID) ([]entities.DispositionNote, error) { + db := DBFromContext(ctx, r.db) + var list []entities.DispositionNote + if len(dispositionIDs) == 0 { + return list, nil + } + if err := db.WithContext(ctx).Where("disposition_id IN ?", dispositionIDs).Order("disposition_id, created_at ASC").Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + type LetterDispositionActionSelectionRepository struct{ db *gorm.DB } func NewLetterDispositionActionSelectionRepository(db *gorm.DB) *LetterDispositionActionSelectionRepository { @@ -150,6 +208,18 @@ func (r *LetterDispositionActionSelectionRepository) ListByDisposition(ctx conte return list, nil } +func (r *LetterDispositionActionSelectionRepository) ListByDispositions(ctx context.Context, dispositionIDs []uuid.UUID) ([]entities.LetterDispositionActionSelection, error) { + db := DBFromContext(ctx, r.db) + var list []entities.LetterDispositionActionSelection + if len(dispositionIDs) == 0 { + return list, nil + } + if err := db.WithContext(ctx).Where("disposition_id IN ?", dispositionIDs).Order("disposition_id, created_at ASC").Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + type LetterDiscussionRepository struct{ db *gorm.DB } func NewLetterDiscussionRepository(db *gorm.DB) *LetterDiscussionRepository { @@ -179,6 +249,35 @@ func (r *LetterDiscussionRepository) Update(ctx context.Context, e *entities.Let Updates(map[string]interface{}{"message": e.Message, "mentions": e.Mentions, "edited_at": e.EditedAt}).Error } +func (r *LetterDiscussionRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterDiscussion, error) { + db := DBFromContext(ctx, r.db) + var list []entities.LetterDiscussion + if err := db.WithContext(ctx). + Where("letter_id = ?", letterID). + Preload("User.Profile"). + Order("created_at ASC"). + Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func (r *LetterDiscussionRepository) GetUsersByIDs(ctx context.Context, userIDs []uuid.UUID) ([]entities.User, error) { + if len(userIDs) == 0 { + return []entities.User{}, nil + } + + db := DBFromContext(ctx, r.db) + var users []entities.User + if err := db.WithContext(ctx). + Where("id IN ?", userIDs). + Preload("Profile"). + Find(&users).Error; err != nil { + return nil, err + } + return users, nil +} + type AppSettingRepository struct{ db *gorm.DB } func NewAppSettingRepository(db *gorm.DB) *AppSettingRepository { return &AppSettingRepository{db: db} } diff --git a/internal/repository/master_repository.go b/internal/repository/master_repository.go index d4ec39f..83fae26 100644 --- a/internal/repository/master_repository.go +++ b/internal/repository/master_repository.go @@ -78,6 +78,20 @@ func (r *InstitutionRepository) List(ctx context.Context) ([]entities.Institutio err := r.db.WithContext(ctx).Order("name ASC").Find(&list).Error return list, err } + +func (r *InstitutionRepository) ListWithSearch(ctx context.Context, search *string) ([]entities.Institution, error) { + var list []entities.Institution + q := r.db.WithContext(ctx).Model(&entities.Institution{}) + + if search != nil && *search != "" { + like := "%" + *search + "%" + q = q.Where("name ILIKE ? OR type ILIKE ? OR address ILIKE ? OR contact_person ILIKE ?", like, like, like, like) + } + + err := q.Order("name ASC").Find(&list).Error + return list, err +} + func (r *InstitutionRepository) Get(ctx context.Context, id uuid.UUID) (*entities.Institution, error) { var e entities.Institution if err := r.db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil { @@ -113,6 +127,17 @@ func (r *DispositionActionRepository) Get(ctx context.Context, id uuid.UUID) (*e return &e, nil } +func (r *DispositionActionRepository) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]entities.DispositionAction, error) { + var actions []entities.DispositionAction + if len(ids) == 0 { + return actions, nil + } + if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&actions).Error; err != nil { + return nil, err + } + return actions, nil +} + type DepartmentRepository struct{ db *gorm.DB } func NewDepartmentRepository(db *gorm.DB) *DepartmentRepository { return &DepartmentRepository{db: db} } @@ -125,3 +150,12 @@ func (r *DepartmentRepository) GetByCode(ctx context.Context, code string) (*ent } return &dep, nil } + +func (r *DepartmentRepository) Get(ctx context.Context, id uuid.UUID) (*entities.Department, error) { + db := DBFromContext(ctx, r.db) + var dep entities.Department + if err := db.WithContext(ctx).First(&dep, "id = ?", id).Error; err != nil { + return nil, err + } + return &dep, nil +} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index 5f711be..412c74f 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -25,7 +25,10 @@ func (r *UserRepositoryImpl) Create(ctx context.Context, user *entities.User) er func (r *UserRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.User, error) { var user entities.User - err := r.b.WithContext(ctx).Preload("Profile").First(&user, "id = ?", id).Error + err := r.b.WithContext(ctx). + Preload("Profile"). + Preload("Departments"). + First(&user, "id = ?", id).Error if err != nil { return nil, err } @@ -34,7 +37,10 @@ func (r *UserRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entiti func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*entities.User, error) { var user entities.User - err := r.b.WithContext(ctx).Preload("Profile").Where("email = ?", email).First(&user).Error + err := r.b.WithContext(ctx). + Preload("Profile"). + Preload("Departments"). + Where("email = ?", email).First(&user).Error if err != nil { return nil, err } @@ -43,7 +49,7 @@ func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*ent func (r *UserRepositoryImpl) GetByRole(ctx context.Context, role entities.UserRole) ([]*entities.User, error) { var users []*entities.User - err := r.b.WithContext(ctx).Preload("Profile").Where("role = ?", role).Find(&users).Error + err := r.b.WithContext(ctx).Preload("Profile").Preload("Departments").Where("role = ?", role).Find(&users).Error return users, err } @@ -52,6 +58,7 @@ func (r *UserRepositoryImpl) GetActiveUsers(ctx context.Context, organizationID err := r.b.WithContext(ctx). Where(" is_active = ?", organizationID, true). Preload("Profile"). + Preload("Departments"). Find(&users).Error return users, err } @@ -90,7 +97,7 @@ func (r *UserRepositoryImpl) List(ctx context.Context, filters map[string]interf return nil, 0, err } - err := query.Limit(limit).Offset(offset).Preload("Profile").Find(&users).Error + err := query.Limit(limit).Offset(offset).Preload("Profile").Preload("Departments").Find(&users).Error return users, total, err } @@ -141,19 +148,19 @@ func (r *UserRepositoryImpl) GetDepartmentsByUserID(ctx context.Context, userID return departments, err } -// GetRolesByUserIDs returns roles per user for a batch of user IDs func (r *UserRepositoryImpl) GetRolesByUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]entities.Role, error) { result := make(map[uuid.UUID][]entities.Role) if len(userIDs) == 0 { return result, nil } - // fetch pairs user_id, role + type row struct { UserID uuid.UUID RoleID uuid.UUID Name string Code string } + var rows []row err := r.b.WithContext(ctx). Table("user_role as ur"). @@ -171,7 +178,6 @@ func (r *UserRepositoryImpl) GetRolesByUserIDs(ctx context.Context, userIDs []uu return result, nil } -// ListWithFilters supports name search and filtering by role code func (r *UserRepositoryImpl) ListWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]*entities.User, int64, error) { var users []*entities.User var total int64 @@ -194,7 +200,7 @@ func (r *UserRepositoryImpl) ListWithFilters(ctx context.Context, search *string return nil, 0, err } - if err := q.Select("users.*").Distinct("users.id").Limit(limit).Offset(offset).Preload("Profile").Find(&users).Error; err != nil { + if err := q.Select("users.*").Distinct("users.id").Limit(limit).Offset(offset).Preload("Profile").Preload("Departments").Find(&users).Error; err != nil { return nil, 0, err } return users, total, nil diff --git a/internal/router/health_handler.go b/internal/router/health_handler.go index b79c48a..611f2a6 100644 --- a/internal/router/health_handler.go +++ b/internal/router/health_handler.go @@ -12,6 +12,7 @@ type UserHandler interface { UpdateProfile(c *gin.Context) ChangePassword(c *gin.Context) ListTitles(c *gin.Context) + GetActiveUsersForMention(c *gin.Context) } type FileHandler interface { @@ -62,7 +63,8 @@ type LetterHandler interface { DeleteIncomingLetter(c *gin.Context) CreateDispositions(c *gin.Context) - ListDispositionsByLetter(c *gin.Context) + //ListDispositionsByLetter(c *gin.Context) + GetEnhancedDispositionsByLetter(c *gin.Context) CreateDiscussion(c *gin.Context) UpdateDiscussion(c *gin.Context) diff --git a/internal/router/router.go b/internal/router/router.go index f31e328..be8b44b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -82,6 +82,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { users.PUT("/profile", r.userHandler.UpdateProfile) users.PUT(":id/password", r.userHandler.ChangePassword) users.GET("/titles", r.userHandler.ListTitles) + users.GET("/mention", r.userHandler.GetActiveUsersForMention) users.POST("/profile/avatar", r.fileHandler.UploadProfileAvatar) } @@ -139,7 +140,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { lettersch.DELETE("/incoming/:id", r.letterHandler.DeleteIncomingLetter) lettersch.POST("/dispositions/:letter_id", r.letterHandler.CreateDispositions) - lettersch.GET("/dispositions/:letter_id", r.letterHandler.ListDispositionsByLetter) + lettersch.GET("/dispositions/:letter_id", r.letterHandler.GetEnhancedDispositionsByLetter) lettersch.POST("/discussions/:letter_id", r.letterHandler.CreateDiscussion) lettersch.PUT("/discussions/:letter_id/:discussion_id", r.letterHandler.UpdateDiscussion) @@ -151,7 +152,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { droutes.POST("", r.dispRouteHandler.Create) droutes.GET(":id", r.dispRouteHandler.Get) droutes.PUT(":id", r.dispRouteHandler.Update) - droutes.GET("from/:from_department_id", r.dispRouteHandler.ListByFromDept) + droutes.GET("department", r.dispRouteHandler.ListByFromDept) droutes.PUT(":id/active", r.dispRouteHandler.SetActive) } } diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 1b692aa..c544ba6 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -58,7 +58,7 @@ func (s *AuthServiceImpl) Login(ctx context.Context, req *contract.LoginRequest) roles, _ := s.userProcessor.GetUserRoles(ctx, userResponse.ID) permCodes, _ := s.userProcessor.GetUserPermissionCodes(ctx, userResponse.ID) - departments, _ := s.userProcessor.GetUserDepartments(ctx, userResponse.ID) + // Departments are now preloaded, so they're already in userResponse token, expiresAt, err := s.generateToken(userResponse, roles, permCodes) if err != nil { @@ -71,7 +71,7 @@ func (s *AuthServiceImpl) Login(ctx context.Context, req *contract.LoginRequest) User: *userResponse, Roles: roles, Permissions: permCodes, - Departments: departments, + Departments: userResponse.DepartmentResponse, }, nil } @@ -90,6 +90,7 @@ func (s *AuthServiceImpl) ValidateToken(tokenString string) (*contract.UserRespo return nil, fmt.Errorf("user account is deactivated") } + // Departments are now preloaded, so they're already in the response return userResponse, nil } @@ -115,14 +116,14 @@ func (s *AuthServiceImpl) RefreshToken(ctx context.Context, tokenString string) return nil, fmt.Errorf("failed to generate token: %w", err) } - departments, _ := s.userProcessor.GetUserDepartments(ctx, userResponse.ID) + // Departments are now preloaded, so they're already in userResponse return &contract.LoginResponse{ Token: newToken, ExpiresAt: expiresAt, User: *userResponse, Roles: roles, Permissions: permCodes, - Departments: departments, + Departments: userResponse.DepartmentResponse, }, nil } diff --git a/internal/service/letter_service.go b/internal/service/letter_service.go index ec6ebeb..165c260 100644 --- a/internal/service/letter_service.go +++ b/internal/service/letter_service.go @@ -16,7 +16,7 @@ type LetterProcessor interface { SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) - ListDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListDispositionsResponse, error) + GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) @@ -50,8 +50,8 @@ func (s *LetterServiceImpl) CreateDispositions(ctx context.Context, req *contrac return s.processor.CreateDispositions(ctx, req) } -func (s *LetterServiceImpl) ListDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListDispositionsResponse, error) { - return s.processor.ListDispositionsByLetter(ctx, letterID) +func (s *LetterServiceImpl) GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) { + return s.processor.GetEnhancedDispositionsByLetter(ctx, letterID) } func (s *LetterServiceImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) { diff --git a/internal/service/master_service.go b/internal/service/master_service.go index b4b22ce..f52e369 100644 --- a/internal/service/master_service.go +++ b/internal/service/master_service.go @@ -140,8 +140,8 @@ func (s *MasterServiceImpl) UpdateInstitution(ctx context.Context, id uuid.UUID, func (s *MasterServiceImpl) DeleteInstitution(ctx context.Context, id uuid.UUID) error { return s.institutionRepo.Delete(ctx, id) } -func (s *MasterServiceImpl) ListInstitutions(ctx context.Context) (*contract.ListInstitutionsResponse, error) { - list, err := s.institutionRepo.List(ctx) +func (s *MasterServiceImpl) ListInstitutions(ctx context.Context, req *contract.ListInstitutionsRequest) (*contract.ListInstitutionsResponse, error) { + list, err := s.institutionRepo.ListWithSearch(ctx, req.Search) if err != nil { return nil, err } diff --git a/internal/service/user_processor.go b/internal/service/user_processor.go index c3d2944..8d4a69f 100644 --- a/internal/service/user_processor.go +++ b/internal/service/user_processor.go @@ -26,4 +26,7 @@ type UserProcessor interface { // New optimized listing ListUsersWithFilters(ctx context.Context, req *contract.ListUsersRequest) ([]contract.UserResponse, int, error) + + // Get active users for mention purposes + GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) } diff --git a/internal/service/user_service.go b/internal/service/user_service.go index 80d5532..3e39159 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -96,3 +96,8 @@ func (s *UserServiceImpl) ListTitles(ctx context.Context) (*contract.ListTitlesR } return &contract.ListTitlesResponse{Titles: transformer.TitlesToContract(titles)}, nil } + +// GetActiveUsersForMention retrieves active users for mention purposes +func (s *UserServiceImpl) GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) { + return s.userProcessor.GetActiveUsersForMention(ctx, search, limit) +} diff --git a/internal/transformer/common_transformer.go b/internal/transformer/common_transformer.go index 0359b57..a5681b3 100644 --- a/internal/transformer/common_transformer.go +++ b/internal/transformer/common_transformer.go @@ -89,6 +89,10 @@ func DepartmentsToContract(positions []entities.Department) []contract.Departmen return res } +func DepartmentToContract(p entities.Department) contract.DepartmentResponse { + return contract.DepartmentResponse{ID: p.ID, Name: p.Name, Code: p.Code, Path: p.Path} +} + func ProfileEntityToContract(p *entities.UserProfile) *contract.UserProfileResponse { if p == nil { return nil @@ -241,7 +245,8 @@ func DispositionRoutesToContract(list []entities.DispositionRoute) []contract.Di if e.AllowedActions != nil { allowed = map[string]interface{}(e.AllowedActions) } - out = append(out, contract.DispositionRouteResponse{ + + resp := contract.DispositionRouteResponse{ ID: e.ID, FromDepartmentID: e.FromDepartmentID, ToDepartmentID: e.ToDepartmentID, @@ -249,7 +254,26 @@ func DispositionRoutesToContract(list []entities.DispositionRoute) []contract.Di AllowedActions: allowed, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, - }) + } + + // Add department information if available + if e.FromDepartment.ID != uuid.Nil { + resp.FromDepartment = contract.DepartmentInfo{ + ID: e.FromDepartment.ID, + Name: e.FromDepartment.Name, + Code: e.FromDepartment.Code, + } + } + + if e.ToDepartment.ID != uuid.Nil { + resp.ToDepartment = contract.DepartmentInfo{ + ID: e.ToDepartment.ID, + Name: e.ToDepartment.Name, + Code: e.ToDepartment.Code, + } + } + + out = append(out, resp) } return out } diff --git a/internal/transformer/letter_transformer.go b/internal/transformer/letter_transformer.go index f6354cf..b85f7df 100644 --- a/internal/transformer/letter_transformer.go +++ b/internal/transformer/letter_transformer.go @@ -3,24 +3,55 @@ package transformer import ( "eslogad-be/internal/contract" "eslogad-be/internal/entities" + + "github.com/google/uuid" ) -func LetterEntityToContract(e *entities.LetterIncoming, attachments []entities.LetterIncomingAttachment) *contract.IncomingLetterResponse { +func LetterEntityToContract(e *entities.LetterIncoming, attachments []entities.LetterIncomingAttachment, refs ...interface{}) *contract.IncomingLetterResponse { resp := &contract.IncomingLetterResponse{ - ID: e.ID, - LetterNumber: e.LetterNumber, - ReferenceNumber: e.ReferenceNumber, - Subject: e.Subject, - Description: e.Description, - PriorityID: e.PriorityID, - SenderInstitutionID: e.SenderInstitutionID, - ReceivedDate: e.ReceivedDate, - DueDate: e.DueDate, - Status: string(e.Status), - CreatedBy: e.CreatedBy, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - Attachments: make([]contract.IncomingLetterAttachmentResponse, 0, len(attachments)), + ID: e.ID, + LetterNumber: e.LetterNumber, + ReferenceNumber: e.ReferenceNumber, + Subject: e.Subject, + Description: e.Description, + ReceivedDate: e.ReceivedDate, + DueDate: e.DueDate, + Status: string(e.Status), + CreatedBy: e.CreatedBy, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Attachments: make([]contract.IncomingLetterAttachmentResponse, 0, len(attachments)), + } + + // optional refs: allow passing already-fetched related objects + // expected ordering (if provided): *entities.Priority, *entities.Institution + for _, r := range refs { + switch v := r.(type) { + case *entities.Priority: + if v != nil { + resp.Priority = &contract.PriorityResponse{ + ID: v.ID.String(), + Name: v.Name, + Level: v.Level, + CreatedAt: v.CreatedAt, + UpdatedAt: v.UpdatedAt, + } + } + case *entities.Institution: + if v != nil { + resp.SenderInstitution = &contract.InstitutionResponse{ + ID: v.ID.String(), + Name: v.Name, + Type: string(v.Type), + Address: v.Address, + ContactPerson: v.ContactPerson, + Phone: v.Phone, + Email: v.Email, + CreatedAt: v.CreatedAt, + UpdatedAt: v.UpdatedAt, + } + } + } } for _, a := range attachments { resp.Attachments = append(resp.Attachments, contract.IncomingLetterAttachmentResponse{ @@ -34,19 +65,171 @@ func LetterEntityToContract(e *entities.LetterIncoming, attachments []entities.L return resp } -func DispositionsToContract(list []entities.LetterDisposition) []contract.DispositionResponse { +func DispositionsToContract(list []entities.LetterIncomingDisposition) []contract.DispositionResponse { out := make([]contract.DispositionResponse, 0, len(list)) for _, d := range list { - out = append(out, contract.DispositionResponse{ + out = append(out, DispoToContract(d)) + } + return out +} + +func DispoToContract(d entities.LetterIncomingDisposition) contract.DispositionResponse { + return contract.DispositionResponse{ + ID: d.ID, + LetterID: d.LetterID, + DepartmentID: d.DepartmentID, + Notes: d.Notes, + ReadAt: d.ReadAt, + CreatedBy: d.CreatedBy, + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + } +} + +func EnhancedDispositionsToContract(list []entities.LetterIncomingDisposition) []contract.EnhancedDispositionResponse { + out := make([]contract.EnhancedDispositionResponse, 0, len(list)) + for _, d := range list { + resp := contract.EnhancedDispositionResponse{ ID: d.ID, LetterID: d.LetterID, - FromDepartmentID: d.FromDepartmentID, - ToDepartmentID: d.ToDepartmentID, + DepartmentID: d.DepartmentID, Notes: d.Notes, - Status: string(d.Status), + ReadAt: d.ReadAt, CreatedBy: d.CreatedBy, CreatedAt: d.CreatedAt, - }) + UpdatedAt: d.UpdatedAt, + Departments: []contract.DispositionDepartmentResponse{}, + Actions: []contract.DispositionActionSelectionResponse{}, + DispositionNotes: []contract.DispositionNoteResponse{}, + } + out = append(out, resp) + } + return out +} + +func DispositionDepartmentsToContract(list []entities.LetterIncomingDispositionDepartment) []contract.DispositionDepartmentResponse { + out := make([]contract.DispositionDepartmentResponse, 0, len(list)) + for _, d := range list { + resp := contract.DispositionDepartmentResponse{ + ID: d.ID, + DepartmentID: d.DepartmentID, + CreatedAt: d.CreatedAt, + } + out = append(out, resp) + } + return out +} + +func DispositionDepartmentsWithDetailsToContract(list []entities.LetterIncomingDispositionDepartment) []contract.DispositionDepartmentResponse { + out := make([]contract.DispositionDepartmentResponse, 0, len(list)) + for _, d := range list { + resp := contract.DispositionDepartmentResponse{ + ID: d.ID, + DepartmentID: d.DepartmentID, + CreatedAt: d.CreatedAt, + } + + // Include department details if preloaded + if d.Department != nil { + resp.Department = &contract.DepartmentResponse{ + ID: d.Department.ID, + Name: d.Department.Name, + Code: d.Department.Code, + Path: d.Department.Path, + } + } + + out = append(out, resp) + } + return out +} + +func DispositionActionSelectionsToContract(list []entities.LetterDispositionActionSelection) []contract.DispositionActionSelectionResponse { + out := make([]contract.DispositionActionSelectionResponse, 0, len(list)) + for _, d := range list { + resp := contract.DispositionActionSelectionResponse{ + ID: d.ID, + ActionID: d.ActionID, + Action: nil, // Will be populated by processor + Note: d.Note, + CreatedBy: d.CreatedBy, + CreatedAt: d.CreatedAt, + } + out = append(out, resp) + } + return out +} + +func DispositionActionSelectionsWithDetailsToContract(list []entities.LetterDispositionActionSelection) []contract.DispositionActionSelectionResponse { + out := make([]contract.DispositionActionSelectionResponse, 0, len(list)) + for _, d := range list { + resp := contract.DispositionActionSelectionResponse{ + ID: d.ID, + ActionID: d.ActionID, + Action: nil, // Will be populated by processor + Note: d.Note, + CreatedBy: d.CreatedBy, + CreatedAt: d.CreatedAt, + } + + // Include action details if preloaded + if d.Action != nil { + resp.Action = &contract.DispositionActionResponse{ + ID: d.Action.ID.String(), + Code: d.Action.Code, + Label: d.Action.Label, + Description: d.Action.Description, + RequiresNote: d.Action.RequiresNote, + GroupName: d.Action.GroupName, + SortOrder: d.Action.SortOrder, + IsActive: d.Action.IsActive, + CreatedAt: d.Action.CreatedAt, + UpdatedAt: d.Action.UpdatedAt, + } + } + + out = append(out, resp) + } + return out +} + +func DispositionNotesToContract(list []entities.DispositionNote) []contract.DispositionNoteResponse { + out := make([]contract.DispositionNoteResponse, 0, len(list)) + for _, d := range list { + resp := contract.DispositionNoteResponse{ + ID: d.ID, + UserID: d.UserID, + Note: d.Note, + CreatedAt: d.CreatedAt, + } + out = append(out, resp) + } + return out +} + +func DispositionNotesWithDetailsToContract(list []entities.DispositionNote) []contract.DispositionNoteResponse { + out := make([]contract.DispositionNoteResponse, 0, len(list)) + for _, d := range list { + resp := contract.DispositionNoteResponse{ + ID: d.ID, + UserID: d.UserID, + Note: d.Note, + CreatedAt: d.CreatedAt, + } + + // Include user details if preloaded + if d.User != nil { + resp.User = &contract.UserResponse{ + ID: d.User.ID, + Name: d.User.Name, + Email: d.User.Email, + IsActive: d.User.IsActive, + CreatedAt: d.User.CreatedAt, + UpdatedAt: d.User.UpdatedAt, + } + } + + out = append(out, resp) } return out } @@ -68,3 +251,138 @@ func DiscussionEntityToContract(e *entities.LetterDiscussion) *contract.LetterDi EditedAt: e.EditedAt, } } + +func DiscussionsWithPreloadedDataToContract(list []entities.LetterDiscussion, mentionedUsers []entities.User) []contract.LetterDiscussionResponse { + // Create a map for efficient user lookup + userMap := make(map[uuid.UUID]entities.User) + for _, user := range mentionedUsers { + userMap[user.ID] = user + } + + out := make([]contract.LetterDiscussionResponse, 0, len(list)) + for _, d := range list { + resp := contract.LetterDiscussionResponse{ + ID: d.ID, + LetterID: d.LetterID, + ParentID: d.ParentID, + UserID: d.UserID, + Message: d.Message, + Mentions: map[string]interface{}(d.Mentions), + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + EditedAt: d.EditedAt, + } + + // Include user profile if preloaded + if d.User != nil { + resp.User = &contract.UserResponse{ + ID: d.User.ID, + Name: d.User.Name, + Email: d.User.Email, + IsActive: d.User.IsActive, + CreatedAt: d.User.CreatedAt, + UpdatedAt: d.User.UpdatedAt, + } + + // Include user profile if available + if d.User.Profile != nil { + resp.User.Profile = &contract.UserProfileResponse{ + UserID: d.User.Profile.UserID, + FullName: d.User.Profile.FullName, + DisplayName: d.User.Profile.DisplayName, + Phone: d.User.Profile.Phone, + AvatarURL: d.User.Profile.AvatarURL, + JobTitle: d.User.Profile.JobTitle, + EmployeeNo: d.User.Profile.EmployeeNo, + Bio: d.User.Profile.Bio, + Timezone: d.User.Profile.Timezone, + Locale: d.User.Profile.Locale, + } + } + } + + // Process mentions to get mentioned users with profiles + if d.Mentions != nil { + mentions := map[string]interface{}(d.Mentions) + if userIDs, ok := mentions["user_ids"]; ok { + if userIDList, ok := userIDs.([]interface{}); ok { + mentionedUsersList := make([]contract.UserResponse, 0) + for _, userID := range userIDList { + if userIDStr, ok := userID.(string); ok { + if userUUID, err := uuid.Parse(userIDStr); err == nil { + if user, exists := userMap[userUUID]; exists { + userResp := contract.UserResponse{ + ID: user.ID, + Name: user.Name, + Email: user.Email, + IsActive: user.IsActive, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } + + // Include user profile if available + if user.Profile != nil { + userResp.Profile = &contract.UserProfileResponse{ + UserID: user.Profile.UserID, + FullName: user.Profile.FullName, + DisplayName: user.Profile.DisplayName, + Phone: user.Profile.Phone, + AvatarURL: user.Profile.AvatarURL, + JobTitle: user.Profile.JobTitle, + EmployeeNo: user.Profile.EmployeeNo, + Bio: user.Profile.Bio, + Timezone: user.Profile.Timezone, + Locale: user.Profile.Locale, + } + } + mentionedUsersList = append(mentionedUsersList, userResp) + } + } + } + } + resp.MentionedUsers = mentionedUsersList + } + } + } + + out = append(out, resp) + } + return out +} + +func EnhancedDispositionsWithPreloadedDataToContract(list []entities.LetterIncomingDisposition) []contract.EnhancedDispositionResponse { + out := make([]contract.EnhancedDispositionResponse, 0, len(list)) + for _, d := range list { + resp := contract.EnhancedDispositionResponse{ + ID: d.ID, + LetterID: d.LetterID, + DepartmentID: d.DepartmentID, + Notes: d.Notes, + ReadAt: d.ReadAt, + CreatedBy: d.CreatedBy, + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + Departments: []contract.DispositionDepartmentResponse{}, + Actions: []contract.DispositionActionSelectionResponse{}, + DispositionNotes: []contract.DispositionNoteResponse{}, + Department: DepartmentToContract(d.Department), + } + + if len(d.Departments) > 0 { + resp.Departments = DispositionDepartmentsWithDetailsToContract(d.Departments) + } + + // Include preloaded action selections with details + if len(d.ActionSelections) > 0 { + resp.Actions = DispositionActionSelectionsWithDetailsToContract(d.ActionSelections) + } + + // Include preloaded notes with user details + if len(d.DispositionNotes) > 0 { + resp.DispositionNotes = DispositionNotesWithDetailsToContract(d.DispositionNotes) + } + + out = append(out, resp) + } + return out +} diff --git a/internal/transformer/user_transformer.go b/internal/transformer/user_transformer.go index 71ed9f7..69da36d 100644 --- a/internal/transformer/user_transformer.go +++ b/internal/transformer/user_transformer.go @@ -37,9 +37,16 @@ func EntityToContract(user *entities.User) *contract.UserResponse { if user == nil { return nil } + + // Use Profile.FullName if available, otherwise fall back to user.Name + displayName := user.Name + if user.Profile != nil && user.Profile.FullName != "" { + displayName = user.Profile.FullName + } + resp := &contract.UserResponse{ ID: user.ID, - Name: user.Profile.FullName, + Name: displayName, Email: user.Email, IsActive: user.IsActive, CreatedAt: user.CreatedAt, @@ -48,6 +55,9 @@ func EntityToContract(user *entities.User) *contract.UserResponse { if user.Profile != nil { resp.Profile = ProfileEntityToContract(user.Profile) } + if user.Departments != nil && len(user.Departments) > 0 { + resp.DepartmentResponse = DepartmentsToContract(user.Departments) + } return resp } diff --git a/migrations/000008_letters_incoming_suite.down.sql b/migrations/000008_letters_incoming_suite.down.sql index b6f9a24..4b4f635 100644 --- a/migrations/000008_letters_incoming_suite.down.sql +++ b/migrations/000008_letters_incoming_suite.down.sql @@ -5,7 +5,8 @@ DROP TABLE IF EXISTS letter_incoming_discussion_attachments; DROP TABLE IF EXISTS letter_incoming_discussions; DROP TABLE IF EXISTS letter_disposition_actions; DROP TABLE IF EXISTS disposition_notes; -DROP TABLE IF EXISTS letter_dispositions; +DROP TABLE IF EXISTS letter_incoming_dispositions_department; +DROP TABLE IF EXISTS letter_incoming_dispositions; DROP TABLE IF EXISTS letter_incoming_attachments; DROP TABLE IF EXISTS letter_incoming_labels; DROP TABLE IF EXISTS letter_incoming_recipients; diff --git a/migrations/000008_letters_incoming_suite.up.sql b/migrations/000008_letters_incoming_suite.up.sql index e580021..6d46bfb 100644 --- a/migrations/000008_letters_incoming_suite.up.sql +++ b/migrations/000008_letters_incoming_suite.up.sql @@ -106,7 +106,7 @@ CREATE TRIGGER trg_letter_dispositions_updated_at -- ======================= CREATE TABLE IF NOT EXISTS disposition_notes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - disposition_id UUID NOT NULL REFERENCES letter_dispositions(id) ON DELETE CASCADE, + disposition_id UUID NOT NULL REFERENCES letter_incoming_dispositions(id) ON DELETE CASCADE, user_id UUID REFERENCES users(id) ON DELETE SET NULL, note TEXT NOT NULL, created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP @@ -119,7 +119,7 @@ CREATE INDEX IF NOT EXISTS idx_disposition_notes_disposition ON disposition_note -- ======================= CREATE TABLE IF NOT EXISTS letter_disposition_actions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - disposition_id UUID NOT NULL REFERENCES letter_dispositions(id) ON DELETE CASCADE, + disposition_id UUID NOT NULL REFERENCES letter_incoming_dispositions(id) ON DELETE CASCADE, action_id UUID NOT NULL REFERENCES disposition_actions(id) ON DELETE RESTRICT, note TEXT, created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, diff --git a/migrations/000012_rename_dispositions_table.down.sql b/migrations/000012_rename_dispositions_table.down.sql new file mode 100644 index 0000000..deb72d5 --- /dev/null +++ b/migrations/000012_rename_dispositions_table.down.sql @@ -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; diff --git a/migrations/000012_rename_dispositions_table.up.sql b/migrations/000012_rename_dispositions_table.up.sql new file mode 100644 index 0000000..817b822 --- /dev/null +++ b/migrations/000012_rename_dispositions_table.up.sql @@ -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; diff --git a/server b/server new file mode 100755 index 0000000..a8d527e Binary files /dev/null and b/server differ