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/Makefile b/Makefile index fc655ba..0572615 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ #PROJECT_NAME = "enaklo-pos-backend" -DB_USERNAME :=eslogad_user -DB_PASSWORD :=M9u%24e%23jT2%40qR4pX%21zL +DB_USERNAME :=metidb +DB_PASSWORD :=metipassword%23123 DB_HOST :=103.191.71.2 -DB_PORT :=5432 -DB_NAME :=eslogad_db +DB_PORT :=5433 +DB_NAME :=mydb DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable diff --git a/deployment.sh b/deployment.sh index 4122a2d..da845a2 100644 --- a/deployment.sh +++ b/deployment.sh @@ -1,7 +1,7 @@ #!/bin/bash -APP_NAME="eslogad" -PORT="4000" +APP_NAME="meti-backend" +PORT="4001" echo "🔄 Pulling latest code..." git pull 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/infra/development.yaml b/infra/development.yaml index 62ed50b..5b28a65 100644 --- a/infra/development.yaml +++ b/infra/development.yaml @@ -10,11 +10,11 @@ jwt: postgresql: host: 103.191.71.2 - port: 5432 + port: 5433 driver: postgres - db: eslogad_db - username: eslogad_user - password: 'M9u$e#jT2@qR4pX!zL' + db: mydb + username: metidb + password: 'metipassword#123' ssl-mode: disable max-idle-connections-in-second: 600 max-open-connections-in-second: 600 diff --git a/internal/app/app.go b/internal/app/app.go index 45c7f1b..2ca32a4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -47,6 +47,7 @@ func (a *App) Initialize(cfg *config.Config) error { masterHandler := handler.NewMasterHandler(services.masterService) letterHandler := handler.NewLetterHandler(services.letterService) dispositionRouteHandler := handler.NewDispositionRouteHandler(services.dispositionRouteService) + voteEventHandler := handler.NewVoteEventHandler(services.voteEventService) a.router = router.NewRouter( cfg, @@ -59,6 +60,7 @@ func (a *App) Initialize(cfg *config.Config) error { masterHandler, letterHandler, dispositionRouteHandler, + voteEventHandler, ) return nil @@ -117,38 +119,42 @@ 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 + voteEventRepo *repository.VoteEventRepositoryImpl } 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), + voteEventRepo: repository.NewVoteEventRepository(a.db), } } @@ -163,7 +169,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, } } @@ -176,6 +182,7 @@ type services struct { masterService *service.MasterServiceImpl letterService *service.LetterServiceImpl dispositionRouteService *service.DispositionRouteServiceImpl + voteEventService *service.VoteEventServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -195,6 +202,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con letterSvc := service.NewLetterService(processors.letterProcessor) dispRouteSvc := service.NewDispositionRouteService(repos.dispositionRouteRepo) + voteEventSvc := service.NewVoteEventService(repos.voteEventRepo) return &services{ userService: userSvc, @@ -204,6 +212,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con masterService: masterSvc, letterService: letterSvc, dispositionRouteService: dispRouteSvc, + voteEventService: voteEventSvc, } } 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/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..fb17fe5 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,48 @@ 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"` +} + +// BulkCreateUsersRequest represents the request for creating multiple users +type BulkCreateUsersRequest struct { + Users []BulkUserRequest `json:"users" validate:"required,min=1,max=100"` +} + +// BulkUserRequest represents a single user in bulk creation request +type BulkUserRequest struct { + Name string `json:"name" validate:"required,min=1,max=255"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=6"` + Role string `json:"role" validate:"required"` +} + +// BulkCreateUsersResponse represents the response for bulk user creation +type BulkCreateUsersResponse struct { + Created []UserResponse `json:"created"` + Failed []BulkUserErrorResult `json:"failed"` + Summary BulkCreationSummary `json:"summary"` +} + +// BulkUserErrorResult represents a failed user creation with error details +type BulkUserErrorResult struct { + User BulkUserRequest `json:"user"` + Error string `json:"error"` +} + +// BulkCreationSummary provides summary of bulk creation results +type BulkCreationSummary struct { + Total int `json:"total"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` +} diff --git a/internal/contract/vote_event_contract.go b/internal/contract/vote_event_contract.go new file mode 100644 index 0000000..b5f3f47 --- /dev/null +++ b/internal/contract/vote_event_contract.go @@ -0,0 +1,96 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +type CreateVoteEventRequest struct { + Title string `json:"title" validate:"required,min=1,max=255"` + Description string `json:"description"` + StartDate time.Time `json:"start_date" validate:"required"` + EndDate time.Time `json:"end_date" validate:"required"` + ResultsOpen *bool `json:"results_open,omitempty"` +} + +type UpdateVoteEventRequest struct { + Title string `json:"title" validate:"required,min=1,max=255"` + Description string `json:"description"` + StartDate time.Time `json:"start_date" validate:"required"` + EndDate time.Time `json:"end_date" validate:"required"` + IsActive bool `json:"is_active"` + ResultsOpen *bool `json:"results_open,omitempty"` +} + +type VoteEventResponse struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + IsActive bool `json:"is_active"` + ResultsOpen bool `json:"results_open"` + IsVotingOpen bool `json:"is_voting_open"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Candidates []CandidateResponse `json:"candidates,omitempty"` +} + +type ListVoteEventsRequest struct { + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` +} + +type ListVoteEventsResponse struct { + VoteEvents []VoteEventResponse `json:"vote_events"` + Pagination PaginationResponse `json:"pagination"` +} + +type CreateCandidateRequest struct { + VoteEventID uuid.UUID `json:"vote_event_id" validate:"required"` + Name string `json:"name" validate:"required,min=1,max=255"` + ImageURL string `json:"image_url"` + Description string `json:"description"` +} + +type CandidateResponse struct { + ID uuid.UUID `json:"id"` + VoteEventID uuid.UUID `json:"vote_event_id"` + Name string `json:"name"` + ImageURL string `json:"image_url"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + VoteCount int64 `json:"vote_count,omitempty"` +} + +type SubmitVoteRequest struct { + VoteEventID uuid.UUID `json:"vote_event_id" validate:"required"` + CandidateID uuid.UUID `json:"candidate_id" validate:"required"` +} + +type VoteResponse struct { + ID uuid.UUID `json:"id"` + VoteEventID uuid.UUID `json:"vote_event_id"` + CandidateID uuid.UUID `json:"candidate_id"` + UserID uuid.UUID `json:"user_id"` + CreatedAt time.Time `json:"created_at"` +} + +type VoteResultsResponse struct { + VoteEventID uuid.UUID `json:"vote_event_id"` + Candidates []CandidateWithVotesResponse `json:"candidates"` + TotalVotes int64 `json:"total_votes"` +} + +type CandidateWithVotesResponse struct { + CandidateResponse + VoteCount int64 `json:"vote_count"` +} + +type CheckVoteStatusResponse struct { + HasVoted bool `json:"has_voted"` + VotedAt *time.Time `json:"voted_at,omitempty"` + CandidateID *uuid.UUID `json:"candidate_id,omitempty"` +} \ No newline at end of file diff --git a/internal/entities/candidate.go b/internal/entities/candidate.go new file mode 100644 index 0000000..a0b6898 --- /dev/null +++ b/internal/entities/candidate.go @@ -0,0 +1,31 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Candidate struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + VoteEventID uuid.UUID `gorm:"type:uuid;not null" json:"vote_event_id"` + Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"` + ImageURL string `gorm:"size:500" json:"image_url"` + Description string `gorm:"type:text" json:"description"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + VoteEvent VoteEvent `gorm:"foreignKey:VoteEventID;references:ID" json:"vote_event,omitempty"` + Votes []Vote `gorm:"foreignKey:CandidateID;references:ID" json:"votes,omitempty"` +} + +func (c *Candidate) BeforeCreate(tx *gorm.DB) error { + if c.ID == uuid.Nil { + c.ID = uuid.New() + } + return nil +} + +func (Candidate) TableName() string { + return "candidates" +} \ No newline at end of file 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/entities/vote.go b/internal/entities/vote.go new file mode 100644 index 0000000..9cad8df --- /dev/null +++ b/internal/entities/vote.go @@ -0,0 +1,30 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Vote struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + VoteEventID uuid.UUID `gorm:"type:uuid;not null" json:"vote_event_id"` + CandidateID uuid.UUID `gorm:"type:uuid;not null" json:"candidate_id"` + UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + VoteEvent VoteEvent `gorm:"foreignKey:VoteEventID;references:ID" json:"vote_event,omitempty"` + Candidate Candidate `gorm:"foreignKey:CandidateID;references:ID" json:"candidate,omitempty"` + User User `gorm:"foreignKey:UserID;references:ID" json:"user,omitempty"` +} + +func (v *Vote) BeforeCreate(tx *gorm.DB) error { + if v.ID == uuid.Nil { + v.ID = uuid.New() + } + return nil +} + +func (Vote) TableName() string { + return "votes" +} \ No newline at end of file diff --git a/internal/entities/vote_event.go b/internal/entities/vote_event.go new file mode 100644 index 0000000..c111302 --- /dev/null +++ b/internal/entities/vote_event.go @@ -0,0 +1,38 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type VoteEvent struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Title string `gorm:"not null;size:255" json:"title" validate:"required,min=1,max=255"` + Description string `gorm:"type:text" json:"description"` + StartDate time.Time `gorm:"not null" json:"start_date" validate:"required"` + EndDate time.Time `gorm:"not null" json:"end_date" validate:"required"` + IsActive bool `gorm:"default:true" json:"is_active"` + ResultsOpen bool `gorm:"default:false" json:"results_open"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + Candidates []Candidate `gorm:"foreignKey:VoteEventID;references:ID" json:"candidates,omitempty"` + Votes []Vote `gorm:"foreignKey:VoteEventID;references:ID" json:"votes,omitempty"` +} + +func (ve *VoteEvent) BeforeCreate(tx *gorm.DB) error { + if ve.ID == uuid.Nil { + ve.ID = uuid.New() + } + return nil +} + +func (VoteEvent) TableName() string { + return "vote_events" +} + +func (ve *VoteEvent) IsVotingOpen() bool { + now := time.Now() + return ve.IsActive && now.After(ve.StartDate) && now.Before(ve.EndDate) +} \ No newline at end of file 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/user_handler.go b/internal/handler/user_handler.go index f200acc..64f075d 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -51,6 +51,43 @@ func (h *UserHandler) CreateUser(c *gin.Context) { c.JSON(http.StatusCreated, userResponse) } +func (h *UserHandler) BulkCreateUsers(c *gin.Context) { + var req contract.BulkCreateUsersRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::BulkCreateUsers -> request binding failed") + h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) + return + } + + if len(req.Users) == 0 { + h.sendValidationErrorResponse(c, "Users list cannot be empty", constants.MissingFieldErrorCode) + return + } + + if len(req.Users) > 100 { + h.sendValidationErrorResponse(c, "Cannot create more than 100 users at once", constants.MissingFieldErrorCode) + return + } + + response, err := h.userService.BulkCreateUsers(c.Request.Context(), &req) + if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::BulkCreateUsers -> Failed to bulk create users") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + statusCode := http.StatusCreated + if response.Summary.Failed > 0 && response.Summary.Succeeded == 0 { + statusCode = http.StatusBadRequest + } else if response.Summary.Failed > 0 { + statusCode = http.StatusMultiStatus + } + + logger.FromContext(c).Infof("UserHandler::BulkCreateUsers -> Successfully processed bulk creation: %d succeeded, %d failed", + response.Summary.Succeeded, response.Summary.Failed) + c.JSON(statusCode, contract.BuildSuccessResponse(response)) +} + func (h *UserHandler) UpdateUser(c *gin.Context) { userIDStr := c.Param("id") userID, err := uuid.Parse(userIDStr) @@ -285,12 +322,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..048bc2b 100644 --- a/internal/handler/user_service.go +++ b/internal/handler/user_service.go @@ -9,6 +9,7 @@ import ( type UserService interface { CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error) + BulkCreateUsers(ctx context.Context, req *contract.BulkCreateUsersRequest) (*contract.BulkCreateUsersResponse, error) UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) DeleteUser(ctx context.Context, id uuid.UUID) error GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) @@ -20,4 +21,6 @@ type UserService interface { UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) ListTitles(ctx context.Context) (*contract.ListTitlesResponse, error) + + GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) } diff --git a/internal/handler/vote_event_handler.go b/internal/handler/vote_event_handler.go new file mode 100644 index 0000000..671b404 --- /dev/null +++ b/internal/handler/vote_event_handler.go @@ -0,0 +1,323 @@ +package handler + +import ( + "context" + "net/http" + "strconv" + + "eslogad-be/internal/appcontext" + "eslogad-be/internal/constants" + "eslogad-be/internal/contract" + "eslogad-be/internal/logger" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type VoteEventService interface { + CreateVoteEvent(ctx context.Context, req *contract.CreateVoteEventRequest) (*contract.VoteEventResponse, error) + GetVoteEventByID(ctx context.Context, id uuid.UUID) (*contract.VoteEventResponse, error) + GetActiveEvents(ctx context.Context) ([]contract.VoteEventResponse, error) + ListVoteEvents(ctx context.Context, req *contract.ListVoteEventsRequest) (*contract.ListVoteEventsResponse, error) + UpdateVoteEvent(ctx context.Context, id uuid.UUID, req *contract.UpdateVoteEventRequest) (*contract.VoteEventResponse, error) + DeleteVoteEvent(ctx context.Context, id uuid.UUID) error + CreateCandidate(ctx context.Context, req *contract.CreateCandidateRequest) (*contract.CandidateResponse, error) + GetCandidates(ctx context.Context, eventID uuid.UUID) ([]contract.CandidateResponse, error) + SubmitVote(ctx context.Context, userID uuid.UUID, req *contract.SubmitVoteRequest) (*contract.VoteResponse, error) + GetVoteResults(ctx context.Context, eventID uuid.UUID) (*contract.VoteResultsResponse, error) + CheckVoteStatus(ctx context.Context, userID, eventID uuid.UUID) (*contract.CheckVoteStatusResponse, error) +} + +type VoteEventHandler struct { + voteEventService VoteEventService +} + +func NewVoteEventHandler(voteEventService VoteEventService) *VoteEventHandler { + return &VoteEventHandler{ + voteEventService: voteEventService, + } +} + +func (h *VoteEventHandler) CreateVoteEvent(c *gin.Context) { + var req contract.CreateVoteEventRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::CreateVoteEvent -> request binding failed") + h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) + return + } + + voteEventResponse, err := h.voteEventService.CreateVoteEvent(c.Request.Context(), &req) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::CreateVoteEvent -> Failed to create vote event") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Infof("VoteEventHandler::CreateVoteEvent -> Successfully created vote event = %+v", voteEventResponse) + c.JSON(http.StatusCreated, contract.BuildSuccessResponse(voteEventResponse)) +} + +func (h *VoteEventHandler) GetVoteEvent(c *gin.Context) { + eventIDStr := c.Param("id") + eventID, err := uuid.Parse(eventIDStr) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetVoteEvent -> Invalid event ID") + h.sendValidationErrorResponse(c, "Invalid event ID", constants.MalformedFieldErrorCode) + return + } + + voteEventResponse, err := h.voteEventService.GetVoteEventByID(c.Request.Context(), eventID) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetVoteEvent -> Failed to get vote event") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Infof("VoteEventHandler::GetVoteEvent -> Successfully retrieved vote event = %+v", voteEventResponse) + c.JSON(http.StatusOK, contract.BuildSuccessResponse(voteEventResponse)) +} + +func (h *VoteEventHandler) GetActiveEvents(c *gin.Context) { + events, err := h.voteEventService.GetActiveEvents(c.Request.Context()) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetActiveEvents -> Failed to get active events") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Infof("VoteEventHandler::GetActiveEvents -> Successfully retrieved %d active events", len(events)) + c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{ + "events": events, + "count": len(events), + })) +} + +func (h *VoteEventHandler) ListVoteEvents(c *gin.Context) { + req := &contract.ListVoteEventsRequest{ + Page: 1, + Limit: 10, + } + + if page := c.Query("page"); page != "" { + if p, err := strconv.Atoi(page); err == nil && p > 0 { + req.Page = p + } + } + + if limit := c.Query("limit"); limit != "" { + if l, err := strconv.Atoi(limit); err == nil && l > 0 && l <= 100 { + req.Limit = l + } + } + + voteEventsResponse, err := h.voteEventService.ListVoteEvents(c.Request.Context(), req) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::ListVoteEvents -> Failed to list vote events") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Infof("VoteEventHandler::ListVoteEvents -> Successfully listed vote events") + c.JSON(http.StatusOK, contract.BuildSuccessResponse(voteEventsResponse)) +} + +func (h *VoteEventHandler) UpdateVoteEvent(c *gin.Context) { + eventIDStr := c.Param("id") + eventID, err := uuid.Parse(eventIDStr) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::UpdateVoteEvent -> Invalid event ID") + h.sendValidationErrorResponse(c, "Invalid event ID", constants.MalformedFieldErrorCode) + return + } + + var req contract.UpdateVoteEventRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::UpdateVoteEvent -> request binding failed") + h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) + return + } + + voteEventResponse, err := h.voteEventService.UpdateVoteEvent(c.Request.Context(), eventID, &req) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::UpdateVoteEvent -> Failed to update vote event") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Infof("VoteEventHandler::UpdateVoteEvent -> Successfully updated vote event = %+v", voteEventResponse) + c.JSON(http.StatusOK, contract.BuildSuccessResponse(voteEventResponse)) +} + +func (h *VoteEventHandler) DeleteVoteEvent(c *gin.Context) { + eventIDStr := c.Param("id") + eventID, err := uuid.Parse(eventIDStr) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::DeleteVoteEvent -> Invalid event ID") + h.sendValidationErrorResponse(c, "Invalid event ID", constants.MalformedFieldErrorCode) + return + } + + err = h.voteEventService.DeleteVoteEvent(c.Request.Context(), eventID) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::DeleteVoteEvent -> Failed to delete vote event") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Info("VoteEventHandler::DeleteVoteEvent -> Successfully deleted vote event") + c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]string{ + "message": "Vote event deleted successfully", + })) +} + +func (h *VoteEventHandler) CreateCandidate(c *gin.Context) { + var req contract.CreateCandidateRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::CreateCandidate -> request binding failed") + h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) + return + } + + candidateResponse, err := h.voteEventService.CreateCandidate(c.Request.Context(), &req) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::CreateCandidate -> Failed to create candidate") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Infof("VoteEventHandler::CreateCandidate -> Successfully created candidate = %+v", candidateResponse) + c.JSON(http.StatusCreated, contract.BuildSuccessResponse(candidateResponse)) +} + +func (h *VoteEventHandler) GetCandidates(c *gin.Context) { + eventIDStr := c.Param("id") + eventID, err := uuid.Parse(eventIDStr) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetCandidates -> Invalid event ID") + h.sendValidationErrorResponse(c, "Invalid event ID", constants.MalformedFieldErrorCode) + return + } + + candidates, err := h.voteEventService.GetCandidates(c.Request.Context(), eventID) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetCandidates -> Failed to get candidates") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Infof("VoteEventHandler::GetCandidates -> Successfully retrieved %d candidates", len(candidates)) + c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{ + "candidates": candidates, + "count": len(candidates), + })) +} + +func (h *VoteEventHandler) SubmitVote(c *gin.Context) { + appCtx := appcontext.FromGinContext(c.Request.Context()) + if appCtx.UserID == uuid.Nil { + h.sendErrorResponse(c, "Unauthorized", http.StatusUnauthorized) + return + } + + var req contract.SubmitVoteRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::SubmitVote -> request binding failed") + h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) + return + } + + voteResponse, err := h.voteEventService.SubmitVote(c.Request.Context(), appCtx.UserID, &req) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::SubmitVote -> Failed to submit vote") + h.sendErrorResponse(c, err.Error(), http.StatusBadRequest) + return + } + + logger.FromContext(c).Infof("VoteEventHandler::SubmitVote -> Successfully submitted vote = %+v", voteResponse) + c.JSON(http.StatusCreated, contract.BuildSuccessResponse(voteResponse)) +} + +func (h *VoteEventHandler) GetVoteResults(c *gin.Context) { + eventIDStr := c.Param("id") + eventID, err := uuid.Parse(eventIDStr) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetVoteResults -> Invalid event ID") + h.sendValidationErrorResponse(c, "Invalid event ID", constants.MalformedFieldErrorCode) + return + } + + voteEvent, err := h.voteEventService.GetVoteEventByID(c.Request.Context(), eventID) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetVoteResults -> Failed to get vote event") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + if !voteEvent.ResultsOpen { + logger.FromContext(c).Info("VoteEventHandler::GetVoteResults -> Results not open for viewing") + h.sendErrorResponse(c, "Results are not open for viewing", http.StatusForbidden) + return + } + + results, err := h.voteEventService.GetVoteResults(c.Request.Context(), eventID) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetVoteResults -> Failed to get vote results") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Infof("VoteEventHandler::GetVoteResults -> Successfully retrieved vote results") + c.JSON(http.StatusOK, contract.BuildSuccessResponse(results)) +} + +func (h *VoteEventHandler) CheckVoteStatus(c *gin.Context) { + appCtx := appcontext.FromGinContext(c.Request.Context()) + if appCtx.UserID == uuid.Nil { + h.sendErrorResponse(c, "Unauthorized", http.StatusUnauthorized) + return + } + + eventIDStr := c.Param("id") + eventID, err := uuid.Parse(eventIDStr) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::CheckVoteStatus -> Invalid event ID") + h.sendValidationErrorResponse(c, "Invalid event ID", constants.MalformedFieldErrorCode) + return + } + + status, err := h.voteEventService.CheckVoteStatus(c.Request.Context(), appCtx.UserID, eventID) + if err != nil { + logger.FromContext(c).WithError(err).Error("VoteEventHandler::CheckVoteStatus -> Failed to check vote status") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Infof("VoteEventHandler::CheckVoteStatus -> Successfully checked vote status") + c.JSON(http.StatusOK, contract.BuildSuccessResponse(status)) +} + +func (h *VoteEventHandler) sendErrorResponse(c *gin.Context, message string, statusCode int) { + errorResponse := &contract.ErrorResponse{ + Error: message, + Code: statusCode, + Details: map[string]interface{}{}, + } + c.JSON(statusCode, errorResponse) +} + +func (h *VoteEventHandler) sendValidationErrorResponse(c *gin.Context, message string, errorCode string) { + statusCode := constants.HttpErrorMap[errorCode] + if statusCode == 0 { + statusCode = http.StatusBadRequest + } + + errorResponse := &contract.ErrorResponse{ + Error: message, + Code: statusCode, + Details: map[string]interface{}{ + "error_code": errorCode, + "entity": "vote_event", + }, + } + c.JSON(statusCode, errorResponse) +} \ No newline at end of file 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/middleware/cors.go b/internal/middleware/cors.go index 224079b..008d954 100644 --- a/internal/middleware/cors.go +++ b/internal/middleware/cors.go @@ -5,11 +5,13 @@ import ( ) func CORS() gin.HandlerFunc { - return gin.HandlerFunc(func(c *gin.Context) { + return func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Credentials", "true") - c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") - c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") + c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-Correlation-ID") + c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") + c.Header("Access-Control-Expose-Headers", "X-Correlation-ID") + c.Header("Access-Control-Max-Age", "86400") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) @@ -17,5 +19,5 @@ func CORS() gin.HandlerFunc { } c.Next() - }) + } } 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..6d3b5cf 100644 --- a/internal/processor/user_processor.go +++ b/internal/processor/user_processor.go @@ -53,7 +53,6 @@ func (p *UserProcessorImpl) CreateUser(ctx context.Context, req *contract.Create return nil, fmt.Errorf("failed to create user: %w", err) } - // create default user profile defaultFullName := userEntity.Name profile := &entities.UserProfile{ UserID: userEntity.ID, @@ -63,6 +62,7 @@ func (p *UserProcessorImpl) CreateUser(ctx context.Context, req *contract.Create Preferences: entities.JSONB{}, NotificationPrefs: entities.JSONB{}, } + _ = p.profileRepo.Create(ctx, profile) return transformer.EntityToContract(userEntity), nil @@ -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..029b911 100644 --- a/internal/repository/master_repository.go +++ b/internal/repository/master_repository.go @@ -113,6 +113,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 +136,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/repository/vote_event_repository.go b/internal/repository/vote_event_repository.go new file mode 100644 index 0000000..f4fd7f0 --- /dev/null +++ b/internal/repository/vote_event_repository.go @@ -0,0 +1,122 @@ +package repository + +import ( + "context" + "time" + + "eslogad-be/internal/entities" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type VoteEventRepositoryImpl struct { + db *gorm.DB +} + +func NewVoteEventRepository(db *gorm.DB) *VoteEventRepositoryImpl { + return &VoteEventRepositoryImpl{ + db: db, + } +} + +func (r *VoteEventRepositoryImpl) Create(ctx context.Context, voteEvent *entities.VoteEvent) error { + return r.db.WithContext(ctx).Create(voteEvent).Error +} + +func (r *VoteEventRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.VoteEvent, error) { + var voteEvent entities.VoteEvent + err := r.db.WithContext(ctx). + Preload("Candidates"). + First(&voteEvent, "id = ?", id).Error + if err != nil { + return nil, err + } + return &voteEvent, nil +} + +func (r *VoteEventRepositoryImpl) GetActiveEvents(ctx context.Context) ([]*entities.VoteEvent, error) { + var events []*entities.VoteEvent + now := time.Now() + err := r.db.WithContext(ctx). + Preload("Candidates"). + Where("is_active = ? AND start_date <= ? AND end_date >= ?", true, now, now). + Find(&events).Error + return events, err +} + +func (r *VoteEventRepositoryImpl) List(ctx context.Context, limit, offset int) ([]*entities.VoteEvent, int64, error) { + var events []*entities.VoteEvent + var total int64 + + if err := r.db.WithContext(ctx).Model(&entities.VoteEvent{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + err := r.db.WithContext(ctx). + Preload("Candidates"). + Limit(limit). + Offset(offset). + Order("created_at DESC"). + Find(&events).Error + return events, total, err +} + +func (r *VoteEventRepositoryImpl) Update(ctx context.Context, voteEvent *entities.VoteEvent) error { + return r.db.WithContext(ctx).Save(voteEvent).Error +} + +func (r *VoteEventRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&entities.VoteEvent{}, "id = ?", id).Error +} + +func (r *VoteEventRepositoryImpl) CreateCandidate(ctx context.Context, candidate *entities.Candidate) error { + return r.db.WithContext(ctx).Create(candidate).Error +} + +func (r *VoteEventRepositoryImpl) GetCandidatesByEventID(ctx context.Context, eventID uuid.UUID) ([]*entities.Candidate, error) { + var candidates []*entities.Candidate + err := r.db.WithContext(ctx). + Where("vote_event_id = ?", eventID). + Find(&candidates).Error + return candidates, err +} + +func (r *VoteEventRepositoryImpl) SubmitVote(ctx context.Context, vote *entities.Vote) error { + return r.db.WithContext(ctx).Create(vote).Error +} + +func (r *VoteEventRepositoryImpl) HasUserVoted(ctx context.Context, userID, eventID uuid.UUID) (bool, error) { + var count int64 + err := r.db.WithContext(ctx). + Model(&entities.Vote{}). + Where("user_id = ? AND vote_event_id = ?", userID, eventID). + Count(&count).Error + return count > 0, err +} + +func (r *VoteEventRepositoryImpl) GetVoteResults(ctx context.Context, eventID uuid.UUID) (map[uuid.UUID]int64, error) { + type result struct { + CandidateID uuid.UUID + VoteCount int64 + } + + var results []result + err := r.db.WithContext(ctx). + Model(&entities.Vote{}). + Select("candidate_id, COUNT(*) as vote_count"). + Where("vote_event_id = ?", eventID). + Group("candidate_id"). + Scan(&results).Error + + if err != nil { + return nil, err + } + + resultMap := make(map[uuid.UUID]int64) + for _, r := range results { + resultMap[r.CandidateID] = r.VoteCount + } + + return resultMap, nil +} \ No newline at end of file diff --git a/internal/router/health_handler.go b/internal/router/health_handler.go index b79c48a..75b9ed5 100644 --- a/internal/router/health_handler.go +++ b/internal/router/health_handler.go @@ -12,6 +12,8 @@ type UserHandler interface { UpdateProfile(c *gin.Context) ChangePassword(c *gin.Context) ListTitles(c *gin.Context) + GetActiveUsersForMention(c *gin.Context) + BulkCreateUsers(c *gin.Context) } type FileHandler interface { @@ -62,7 +64,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) @@ -75,3 +78,17 @@ type DispositionRouteHandler interface { ListByFromDept(c *gin.Context) SetActive(c *gin.Context) } + +type VoteEventHandler interface { + CreateVoteEvent(c *gin.Context) + GetVoteEvent(c *gin.Context) + GetActiveEvents(c *gin.Context) + ListVoteEvents(c *gin.Context) + UpdateVoteEvent(c *gin.Context) + DeleteVoteEvent(c *gin.Context) + CreateCandidate(c *gin.Context) + SubmitVote(c *gin.Context) + GetVoteResults(c *gin.Context) + CheckVoteStatus(c *gin.Context) + GetCandidates(c *gin.Context) +} diff --git a/internal/router/router.go b/internal/router/router.go index f31e328..8dc5824 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -18,6 +18,7 @@ type Router struct { masterHandler MasterHandler letterHandler LetterHandler dispRouteHandler DispositionRouteHandler + voteEventHandler VoteEventHandler } func NewRouter( @@ -31,6 +32,7 @@ func NewRouter( masterHandler MasterHandler, letterHandler LetterHandler, dispRouteHandler DispositionRouteHandler, + voteEventHandler VoteEventHandler, ) *Router { return &Router{ config: cfg, @@ -43,6 +45,7 @@ func NewRouter( masterHandler: masterHandler, letterHandler: letterHandler, dispRouteHandler: dispRouteHandler, + voteEventHandler: voteEventHandler, } } @@ -50,12 +53,12 @@ func (r *Router) Init() *gin.Engine { gin.SetMode(gin.ReleaseMode) engine := gin.New() engine.Use( + middleware.CORS(), middleware.JsonAPI(), middleware.CorrelationID(), middleware.Recover(), middleware.HTTPStatLogger(), middleware.PopulateContext(), - middleware.CORS(), ) r.addAppRoutes(engine) @@ -78,10 +81,12 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { users.Use(r.authMiddleware.RequireAuth()) { users.GET("", r.authMiddleware.RequirePermissions("user.read"), r.userHandler.ListUsers) + users.POST("/bulk", r.userHandler.BulkCreateUsers) users.GET("/profile", r.userHandler.GetProfile) 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 +144,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,8 +156,34 @@ 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) } + + voteEvents := v1.Group("/vote-events") + voteEvents.Use(r.authMiddleware.RequireAuth()) + { + voteEvents.POST("", r.voteEventHandler.CreateVoteEvent) + voteEvents.GET("", r.voteEventHandler.ListVoteEvents) + voteEvents.GET("/active", r.voteEventHandler.GetActiveEvents) + voteEvents.GET("/:id", r.voteEventHandler.GetVoteEvent) + voteEvents.PUT("/:id", r.voteEventHandler.UpdateVoteEvent) + voteEvents.DELETE("/:id", r.voteEventHandler.DeleteVoteEvent) + voteEvents.GET("/:id/candidates", r.voteEventHandler.GetCandidates) + voteEvents.GET("/:id/results", r.voteEventHandler.GetVoteResults) + voteEvents.GET("/:id/vote-status", r.voteEventHandler.CheckVoteStatus) + } + + candidates := v1.Group("/candidates") + candidates.Use(r.authMiddleware.RequireAuth()) + { + candidates.POST("", r.voteEventHandler.CreateCandidate) + } + + votes := v1.Group("/votes") + votes.Use(r.authMiddleware.RequireAuth()) + { + votes.POST("", r.voteEventHandler.SubmitVote) + } } } 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/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..edc8f4f 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -30,6 +30,40 @@ func (s *UserServiceImpl) CreateUser(ctx context.Context, req *contract.CreateUs return s.userProcessor.CreateUser(ctx, req) } +func (s *UserServiceImpl) BulkCreateUsers(ctx context.Context, req *contract.BulkCreateUsersRequest) (*contract.BulkCreateUsersResponse, error) { + response := &contract.BulkCreateUsersResponse{ + Created: []contract.UserResponse{}, + Failed: []contract.BulkUserErrorResult{}, + Summary: contract.BulkCreationSummary{ + Total: len(req.Users), + Succeeded: 0, + Failed: 0, + }, + } + + for _, userReq := range req.Users { + createReq := &contract.CreateUserRequest{ + Name: userReq.Name, + Email: userReq.Email, + Password: userReq.Password, + } + + userResponse, err := s.userProcessor.CreateUser(ctx, createReq) + if err != nil { + response.Failed = append(response.Failed, contract.BulkUserErrorResult{ + User: userReq, + Error: err.Error(), + }) + response.Summary.Failed++ + } else { + response.Created = append(response.Created, *userResponse) + response.Summary.Succeeded++ + } + } + + return response, nil +} + func (s *UserServiceImpl) UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) { return s.userProcessor.UpdateUser(ctx, id, req) } @@ -96,3 +130,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/service/vote_event_service.go b/internal/service/vote_event_service.go new file mode 100644 index 0000000..53b30e5 --- /dev/null +++ b/internal/service/vote_event_service.go @@ -0,0 +1,258 @@ +package service + +import ( + "context" + "errors" + + "eslogad-be/internal/contract" + "eslogad-be/internal/entities" + "eslogad-be/internal/transformer" + + "github.com/google/uuid" +) + +type VoteEventRepository interface { + Create(ctx context.Context, voteEvent *entities.VoteEvent) error + GetByID(ctx context.Context, id uuid.UUID) (*entities.VoteEvent, error) + GetActiveEvents(ctx context.Context) ([]*entities.VoteEvent, error) + List(ctx context.Context, limit, offset int) ([]*entities.VoteEvent, int64, error) + Update(ctx context.Context, voteEvent *entities.VoteEvent) error + Delete(ctx context.Context, id uuid.UUID) error + CreateCandidate(ctx context.Context, candidate *entities.Candidate) error + GetCandidatesByEventID(ctx context.Context, eventID uuid.UUID) ([]*entities.Candidate, error) + SubmitVote(ctx context.Context, vote *entities.Vote) error + HasUserVoted(ctx context.Context, userID, eventID uuid.UUID) (bool, error) + GetVoteResults(ctx context.Context, eventID uuid.UUID) (map[uuid.UUID]int64, error) +} + +type VoteEventServiceImpl struct { + voteEventRepo VoteEventRepository +} + +func NewVoteEventService(voteEventRepo VoteEventRepository) *VoteEventServiceImpl { + return &VoteEventServiceImpl{ + voteEventRepo: voteEventRepo, + } +} + +func (s *VoteEventServiceImpl) CreateVoteEvent(ctx context.Context, req *contract.CreateVoteEventRequest) (*contract.VoteEventResponse, error) { + if req.EndDate.Before(req.StartDate) { + return nil, errors.New("end date must be after start date") + } + + voteEvent := &entities.VoteEvent{ + Title: req.Title, + Description: req.Description, + StartDate: req.StartDate, + EndDate: req.EndDate, + IsActive: true, + ResultsOpen: false, + } + + if req.ResultsOpen != nil { + voteEvent.ResultsOpen = *req.ResultsOpen + } + + if err := s.voteEventRepo.Create(ctx, voteEvent); err != nil { + return nil, err + } + + return transformer.VoteEventToContract(voteEvent), nil +} + +func (s *VoteEventServiceImpl) GetVoteEventByID(ctx context.Context, id uuid.UUID) (*contract.VoteEventResponse, error) { + voteEvent, err := s.voteEventRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + return transformer.VoteEventToContract(voteEvent), nil +} + +func (s *VoteEventServiceImpl) GetActiveEvents(ctx context.Context) ([]contract.VoteEventResponse, error) { + events, err := s.voteEventRepo.GetActiveEvents(ctx) + if err != nil { + return nil, err + } + + var responses []contract.VoteEventResponse + for _, event := range events { + responses = append(responses, *transformer.VoteEventToContract(event)) + } + + return responses, nil +} + +func (s *VoteEventServiceImpl) ListVoteEvents(ctx context.Context, req *contract.ListVoteEventsRequest) (*contract.ListVoteEventsResponse, error) { + page := req.Page + if page <= 0 { + page = 1 + } + limit := req.Limit + if limit <= 0 { + limit = 10 + } + + offset := (page - 1) * limit + + events, total, err := s.voteEventRepo.List(ctx, limit, offset) + if err != nil { + return nil, err + } + + var responses []contract.VoteEventResponse + for _, event := range events { + responses = append(responses, *transformer.VoteEventToContract(event)) + } + + return &contract.ListVoteEventsResponse{ + VoteEvents: responses, + Pagination: transformer.CreatePaginationResponse(int(total), page, limit), + }, nil +} + +func (s *VoteEventServiceImpl) UpdateVoteEvent(ctx context.Context, id uuid.UUID, req *contract.UpdateVoteEventRequest) (*contract.VoteEventResponse, error) { + if req.EndDate.Before(req.StartDate) { + return nil, errors.New("end date must be after start date") + } + + voteEvent, err := s.voteEventRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + voteEvent.Title = req.Title + voteEvent.Description = req.Description + voteEvent.StartDate = req.StartDate + voteEvent.EndDate = req.EndDate + voteEvent.IsActive = req.IsActive + + if req.ResultsOpen != nil { + voteEvent.ResultsOpen = *req.ResultsOpen + } + + if err := s.voteEventRepo.Update(ctx, voteEvent); err != nil { + return nil, err + } + + return transformer.VoteEventToContract(voteEvent), nil +} + +func (s *VoteEventServiceImpl) DeleteVoteEvent(ctx context.Context, id uuid.UUID) error { + return s.voteEventRepo.Delete(ctx, id) +} + +func (s *VoteEventServiceImpl) CreateCandidate(ctx context.Context, req *contract.CreateCandidateRequest) (*contract.CandidateResponse, error) { + voteEvent, err := s.voteEventRepo.GetByID(ctx, req.VoteEventID) + if err != nil { + return nil, err + } + + if !voteEvent.IsActive { + return nil, errors.New("cannot add candidates to inactive vote event") + } + + candidate := &entities.Candidate{ + VoteEventID: req.VoteEventID, + Name: req.Name, + ImageURL: req.ImageURL, + Description: req.Description, + } + + if err := s.voteEventRepo.CreateCandidate(ctx, candidate); err != nil { + return nil, err + } + + return transformer.CandidateToContract(candidate), nil +} + +func (s *VoteEventServiceImpl) GetCandidates(ctx context.Context, eventID uuid.UUID) ([]contract.CandidateResponse, error) { + candidates, err := s.voteEventRepo.GetCandidatesByEventID(ctx, eventID) + if err != nil { + return nil, err + } + + var responses []contract.CandidateResponse + for _, candidate := range candidates { + responses = append(responses, *transformer.CandidateToContract(candidate)) + } + + return responses, nil +} + +func (s *VoteEventServiceImpl) SubmitVote(ctx context.Context, userID uuid.UUID, req *contract.SubmitVoteRequest) (*contract.VoteResponse, error) { + voteEvent, err := s.voteEventRepo.GetByID(ctx, req.VoteEventID) + if err != nil { + return nil, err + } + + if !voteEvent.IsVotingOpen() { + return nil, errors.New("voting is not open for this event") + } + + hasVoted, err := s.voteEventRepo.HasUserVoted(ctx, userID, req.VoteEventID) + if err != nil { + return nil, err + } + + if hasVoted { + return nil, errors.New("user has already voted for this event") + } + + vote := &entities.Vote{ + VoteEventID: req.VoteEventID, + CandidateID: req.CandidateID, + UserID: userID, + } + + if err := s.voteEventRepo.SubmitVote(ctx, vote); err != nil { + return nil, err + } + + return transformer.VoteToContract(vote), nil +} + +func (s *VoteEventServiceImpl) GetVoteResults(ctx context.Context, eventID uuid.UUID) (*contract.VoteResultsResponse, error) { + + candidates, err := s.voteEventRepo.GetCandidatesByEventID(ctx, eventID) + if err != nil { + return nil, err + } + + voteResults, err := s.voteEventRepo.GetVoteResults(ctx, eventID) + if err != nil { + return nil, err + } + + var candidatesWithVotes []contract.CandidateWithVotesResponse + var totalVotes int64 + + for _, candidate := range candidates { + voteCount := voteResults[candidate.ID] + totalVotes += voteCount + + candidatesWithVotes = append(candidatesWithVotes, contract.CandidateWithVotesResponse{ + CandidateResponse: *transformer.CandidateToContract(candidate), + VoteCount: voteCount, + }) + } + + return &contract.VoteResultsResponse{ + VoteEventID: eventID, + Candidates: candidatesWithVotes, + TotalVotes: totalVotes, + }, nil +} + +func (s *VoteEventServiceImpl) CheckVoteStatus(ctx context.Context, userID, eventID uuid.UUID) (*contract.CheckVoteStatusResponse, error) { + hasVoted, err := s.voteEventRepo.HasUserVoted(ctx, userID, eventID) + if err != nil { + return nil, err + } + + response := &contract.CheckVoteStatusResponse{ + HasVoted: hasVoted, + } + + return response, nil +} 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/internal/transformer/vote_event_transformer.go b/internal/transformer/vote_event_transformer.go new file mode 100644 index 0000000..ccb1d75 --- /dev/null +++ b/internal/transformer/vote_event_transformer.go @@ -0,0 +1,76 @@ +package transformer + +import ( + "eslogad-be/internal/contract" + "eslogad-be/internal/entities" +) + +func VoteEventToContract(voteEvent *entities.VoteEvent) *contract.VoteEventResponse { + if voteEvent == nil { + return nil + } + + response := &contract.VoteEventResponse{ + ID: voteEvent.ID, + Title: voteEvent.Title, + Description: voteEvent.Description, + StartDate: voteEvent.StartDate, + EndDate: voteEvent.EndDate, + IsActive: voteEvent.IsActive, + ResultsOpen: voteEvent.ResultsOpen, + IsVotingOpen: voteEvent.IsVotingOpen(), + CreatedAt: voteEvent.CreatedAt, + UpdatedAt: voteEvent.UpdatedAt, + } + + if voteEvent.Candidates != nil && len(voteEvent.Candidates) > 0 { + response.Candidates = CandidatesToContract(voteEvent.Candidates) + } + + return response +} + +func CandidateToContract(candidate *entities.Candidate) *contract.CandidateResponse { + if candidate == nil { + return nil + } + + return &contract.CandidateResponse{ + ID: candidate.ID, + VoteEventID: candidate.VoteEventID, + Name: candidate.Name, + ImageURL: candidate.ImageURL, + Description: candidate.Description, + CreatedAt: candidate.CreatedAt, + UpdatedAt: candidate.UpdatedAt, + } +} + +func CandidatesToContract(candidates []entities.Candidate) []contract.CandidateResponse { + if candidates == nil { + return nil + } + + responses := make([]contract.CandidateResponse, len(candidates)) + for i, c := range candidates { + resp := CandidateToContract(&c) + if resp != nil { + responses[i] = *resp + } + } + return responses +} + +func VoteToContract(vote *entities.Vote) *contract.VoteResponse { + if vote == nil { + return nil + } + + return &contract.VoteResponse{ + ID: vote.ID, + VoteEventID: vote.VoteEventID, + CandidateID: vote.CandidateID, + UserID: vote.UserID, + CreatedAt: vote.CreatedAt, + } +} \ No newline at end of file diff --git a/migrations/000005_title_table.down.sql b/migrations/000005_title_table.down.sql deleted file mode 100644 index e69de29..0000000 diff --git a/migrations/000005_title_table.up.sql b/migrations/000005_title_table.up.sql deleted file mode 100644 index 973039d..0000000 --- a/migrations/000005_title_table.up.sql +++ /dev/null @@ -1,60 +0,0 @@ --- ======================= --- TITLES --- ======================= -CREATE TABLE IF NOT EXISTS titles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, -- e.g., "Senior Software Engineer" - code TEXT UNIQUE, -- e.g., "senior-software-engineer" - description TEXT, -- optional: extra details - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP - ); - --- Trigger for updated_at -CREATE TRIGGER trg_titles_updated_at - BEFORE UPDATE ON titles - FOR EACH ROW - EXECUTE FUNCTION set_updated_at(); - --- Perwira Tinggi (High-ranking Officers) -INSERT INTO titles (name, code, description) VALUES - ('Jenderal', 'jenderal', 'Pangkat tertinggi di TNI AD'), - ('Letnan Jenderal', 'letnan-jenderal', 'Pangkat tinggi di bawah Jenderal'), - ('Mayor Jenderal', 'mayor-jenderal', 'Pangkat tinggi di bawah Letnan Jenderal'), - ('Brigadir Jenderal', 'brigadir-jenderal', 'Pangkat tinggi di bawah Mayor Jenderal'); - --- Perwira Menengah (Middle-ranking Officers) -INSERT INTO titles (name, code, description) VALUES - ('Kolonel', 'kolonel', 'Pangkat perwira menengah tertinggi'), - ('Letnan Kolonel', 'letnan-kolonel', 'Pangkat perwira menengah di bawah Kolonel'), - ('Mayor', 'mayor', 'Pangkat perwira menengah di bawah Letnan Kolonel'); - --- Perwira Pertama (Junior Officers) -INSERT INTO titles (name, code, description) VALUES - ('Kapten', 'kapten', 'Pangkat perwira pertama tertinggi'), - ('Letnan Satu', 'letnan-satu', 'Pangkat perwira pertama di bawah Kapten'), - ('Letnan Dua', 'letnan-dua', 'Pangkat perwira pertama di bawah Letnan Satu'); - --- Bintara Tinggi (Senior NCOs) -INSERT INTO titles (name, code, description) VALUES - ('Pembantu Letnan Satu', 'pembantu-letnan-satu', 'Pangkat bintara tinggi tertinggi'), - ('Pembantu Letnan Dua', 'pembantu-letnan-dua', 'Pangkat bintara tinggi di bawah Pelda'); - --- Bintara (NCOs) -INSERT INTO titles (name, code, description) VALUES - ('Sersan Mayor', 'sersan-mayor', 'Pangkat bintara di bawah Pelda'), - ('Sersan Kepala', 'sersan-kepala', 'Pangkat bintara di bawah Serma'), - ('Sersan Satu', 'sersan-satu', 'Pangkat bintara di bawah Serka'), - ('Sersan Dua', 'sersan-dua', 'Pangkat bintara di bawah Sertu'); - --- Tamtama Tinggi (Senior Enlisted) -INSERT INTO titles (name, code, description) VALUES - ('Kopral Kepala', 'kopral-kepala', 'Pangkat tamtama tinggi tertinggi'), - ('Kopral Satu', 'kopral-satu', 'Pangkat tamtama tinggi di bawah Kopka'), - ('Kopral Dua', 'kopral-dua', 'Pangkat tamtama tinggi di bawah Koptu'); - --- Tamtama (Enlisted) -INSERT INTO titles (name, code, description) VALUES - ('Prajurit Kepala', 'prajurit-kepala', 'Pangkat tamtama di bawah Kopda'), - ('Prajurit Satu', 'prajurit-satu', 'Pangkat tamtama di bawah Prada'), - ('Prajurit Dua', 'prajurit-dua', 'Pangkat tamtama terendah'); diff --git a/migrations/000006_labels_priorities_institutions.down.sql b/migrations/000006_labels_priorities_institutions.down.sql deleted file mode 100644 index a720293..0000000 --- a/migrations/000006_labels_priorities_institutions.down.sql +++ /dev/null @@ -1,7 +0,0 @@ -BEGIN; - -DROP TABLE IF EXISTS institutions; -DROP TABLE IF EXISTS priorities; -DROP TABLE IF EXISTS labels; - -COMMIT; \ No newline at end of file diff --git a/migrations/000006_labels_priorities_institutions.up.sql b/migrations/000006_labels_priorities_institutions.up.sql deleted file mode 100644 index 6bf3307..0000000 --- a/migrations/000006_labels_priorities_institutions.up.sql +++ /dev/null @@ -1,52 +0,0 @@ -BEGIN; - --- ======================= --- LABELS --- ======================= -CREATE TABLE IF NOT EXISTS labels ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - color VARCHAR(16), -- HEX color code (e.g., #FF0000) - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TRIGGER trg_labels_updated_at - BEFORE UPDATE ON labels - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- PRIORITIES --- ======================= -CREATE TABLE IF NOT EXISTS priorities ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - level INT NOT NULL, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TRIGGER trg_priorities_updated_at - BEFORE UPDATE ON priorities - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- INSTITUTIONS --- ======================= -CREATE TABLE IF NOT EXISTS institutions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - type TEXT NOT NULL CHECK (type IN ('government','private','ngo','individual')), - address TEXT, - contact_person VARCHAR(255), - phone VARCHAR(50), - email VARCHAR(255), - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TRIGGER trg_institutions_updated_at - BEFORE UPDATE ON institutions - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -COMMIT; \ No newline at end of file diff --git a/migrations/000007_disposition_actions.down.sql b/migrations/000007_disposition_actions.down.sql deleted file mode 100644 index 8a9a41c..0000000 --- a/migrations/000007_disposition_actions.down.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN; - -DROP TABLE IF EXISTS disposition_actions; - -COMMIT; \ No newline at end of file diff --git a/migrations/000007_disposition_actions.up.sql b/migrations/000007_disposition_actions.up.sql deleted file mode 100644 index dc8b68e..0000000 --- a/migrations/000007_disposition_actions.up.sql +++ /dev/null @@ -1,23 +0,0 @@ -BEGIN; - --- ======================= --- DISPOSITION ACTIONS --- ======================= -CREATE TABLE IF NOT EXISTS disposition_actions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code TEXT UNIQUE NOT NULL, - label TEXT NOT NULL, - description TEXT, - requires_note BOOLEAN NOT NULL DEFAULT FALSE, - group_name TEXT, - sort_order INT, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TRIGGER trg_disposition_actions_updated_at - BEFORE UPDATE ON disposition_actions - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -COMMIT; \ No newline at end of file diff --git a/migrations/000008_letters_incoming_suite.down.sql b/migrations/000008_letters_incoming_suite.down.sql deleted file mode 100644 index b6f9a24..0000000 --- a/migrations/000008_letters_incoming_suite.down.sql +++ /dev/null @@ -1,16 +0,0 @@ -BEGIN; - -DROP TABLE IF EXISTS letter_incoming_activity_logs; -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_attachments; -DROP TABLE IF EXISTS letter_incoming_labels; -DROP TABLE IF EXISTS letter_incoming_recipients; -DROP TABLE IF EXISTS letters_incoming; - -DROP SEQUENCE IF EXISTS letters_incoming_seq; - -COMMIT; \ No newline at end of file diff --git a/migrations/000008_letters_incoming_suite.up.sql b/migrations/000008_letters_incoming_suite.up.sql deleted file mode 100644 index e580021..0000000 --- a/migrations/000008_letters_incoming_suite.up.sql +++ /dev/null @@ -1,189 +0,0 @@ -BEGIN; - --- ======================= --- SEQUENCE FOR LETTER NUMBER --- ======================= -CREATE SEQUENCE IF NOT EXISTS letters_incoming_seq; - --- ======================= --- LETTERS INCOMING --- ======================= -CREATE TABLE IF NOT EXISTS letters_incoming ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_number TEXT NOT NULL UNIQUE DEFAULT ('IN-' || lpad(nextval('letters_incoming_seq')::text, 8, '0')), - reference_number TEXT, - subject TEXT NOT NULL, - description TEXT, - priority_id UUID REFERENCES priorities(id) ON DELETE SET NULL, - sender_institution_id UUID REFERENCES institutions(id) ON DELETE SET NULL, - received_date DATE NOT NULL, - due_date DATE, - status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','in_progress','completed')), - created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITHOUT TIME ZONE -); - -CREATE INDEX IF NOT EXISTS idx_letters_incoming_status ON letters_incoming(status); -CREATE INDEX IF NOT EXISTS idx_letters_incoming_received_date ON letters_incoming(received_date); - -CREATE TRIGGER trg_letters_incoming_updated_at - BEFORE UPDATE ON letters_incoming - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- LETTER INCOMING RECIPIENTS --- ======================= -CREATE TABLE IF NOT EXISTS letter_incoming_recipients ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE, - recipient_user_id UUID REFERENCES users(id) ON DELETE SET NULL, - recipient_department_id UUID REFERENCES departments(id) ON DELETE SET NULL, - status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','read','completed')), - read_at TIMESTAMP WITHOUT TIME ZONE, - completed_at TIMESTAMP WITHOUT TIME ZONE, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_letter_incoming_recipients_letter ON letter_incoming_recipients(letter_id); - --- ======================= --- LETTER INCOMING LABELS (M:N) --- ======================= -CREATE TABLE IF NOT EXISTS letter_incoming_labels ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE, - label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - UNIQUE (letter_id, label_id) -); - -CREATE INDEX IF NOT EXISTS idx_letter_incoming_labels_letter ON letter_incoming_labels(letter_id); - --- ======================= --- LETTER INCOMING ATTACHMENTS --- ======================= -CREATE TABLE IF NOT EXISTS letter_incoming_attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE, - file_url TEXT NOT NULL, - file_name TEXT NOT NULL, - file_type TEXT NOT NULL, - uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL, - uploaded_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_letter_incoming_attachments_letter ON letter_incoming_attachments(letter_id); - --- ======================= --- LETTER DISPOSITIONS --- ======================= -CREATE TABLE IF NOT EXISTS letter_dispositions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE, - from_user_id UUID REFERENCES users(id) ON DELETE SET NULL, - from_department_id UUID REFERENCES departments(id) ON DELETE SET NULL, - to_user_id UUID REFERENCES users(id) ON DELETE SET NULL, - to_department_id UUID REFERENCES departments(id) ON DELETE SET NULL, - notes TEXT, - status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','read','rejected','completed')), - created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - read_at TIMESTAMP WITHOUT TIME ZONE, - completed_at TIMESTAMP WITHOUT TIME ZONE, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_letter_dispositions_letter ON letter_dispositions(letter_id); - -CREATE TRIGGER trg_letter_dispositions_updated_at - BEFORE UPDATE ON letter_dispositions - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- DISPOSITION NOTES --- ======================= -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, - user_id UUID REFERENCES users(id) ON DELETE SET NULL, - note TEXT NOT NULL, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_disposition_notes_disposition ON disposition_notes(disposition_id); - --- ======================= --- LETTER DISPOSITION ACTIONS (Selections) --- ======================= -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, - 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, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - UNIQUE (disposition_id, action_id) -); - -CREATE INDEX IF NOT EXISTS idx_letter_disposition_actions_disposition ON letter_disposition_actions(disposition_id); - --- ======================= --- LETTER INCOMING DISCUSSIONS (Threaded) --- ======================= -CREATE TABLE IF NOT EXISTS letter_incoming_discussions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE, - parent_id UUID REFERENCES letter_incoming_discussions(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - message TEXT NOT NULL, - mentions JSONB, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - edited_at TIMESTAMP WITHOUT TIME ZONE -); - -CREATE INDEX IF NOT EXISTS idx_letter_incoming_discussions_letter ON letter_incoming_discussions(letter_id); -CREATE INDEX IF NOT EXISTS idx_letter_incoming_discussions_parent ON letter_incoming_discussions(parent_id); - -CREATE TRIGGER trg_letter_incoming_discussions_updated_at - BEFORE UPDATE ON letter_incoming_discussions - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- LETTER INCOMING DISCUSSION ATTACHMENTS --- ======================= -CREATE TABLE IF NOT EXISTS letter_incoming_discussion_attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - discussion_id UUID NOT NULL REFERENCES letter_incoming_discussions(id) ON DELETE CASCADE, - file_url TEXT NOT NULL, - file_name TEXT NOT NULL, - file_type TEXT NOT NULL, - uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL, - uploaded_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_letter_incoming_discussion_attachments_discussion ON letter_incoming_discussion_attachments(discussion_id); - --- ======================= --- LETTER INCOMING ACTIVITY LOGS (Immutable) --- ======================= -CREATE TABLE IF NOT EXISTS letter_incoming_activity_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE, - action_type TEXT NOT NULL, - actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL, - actor_department_id UUID REFERENCES departments(id) ON DELETE SET NULL, - target_type TEXT, - target_id UUID, - from_status TEXT, - to_status TEXT, - context JSONB, - occurred_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_letter_incoming_activity_logs_letter ON letter_incoming_activity_logs(letter_id); -CREATE INDEX IF NOT EXISTS idx_letter_incoming_activity_logs_action ON letter_incoming_activity_logs(action_type); - -COMMIT; \ No newline at end of file diff --git a/migrations/000009_disposition_routes.down.sql b/migrations/000009_disposition_routes.down.sql deleted file mode 100644 index edfdb46..0000000 --- a/migrations/000009_disposition_routes.down.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN; - -DROP TABLE IF EXISTS disposition_routes; - -COMMIT; \ No newline at end of file diff --git a/migrations/000009_disposition_routes.up.sql b/migrations/000009_disposition_routes.up.sql deleted file mode 100644 index ef987ea..0000000 --- a/migrations/000009_disposition_routes.up.sql +++ /dev/null @@ -1,27 +0,0 @@ -BEGIN; - --- ======================= --- DISPOSITION ROUTES --- ======================= -CREATE TABLE IF NOT EXISTS disposition_routes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - from_department_id UUID NOT NULL REFERENCES departments(id) ON DELETE CASCADE, - to_department_id UUID NOT NULL REFERENCES departments(id) ON DELETE CASCADE, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - allowed_actions JSONB, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_disposition_routes_from_dept ON disposition_routes(from_department_id); - --- Prevent duplicate active routes from -> to -CREATE UNIQUE INDEX IF NOT EXISTS uq_disposition_routes_active - ON disposition_routes(from_department_id, to_department_id) - WHERE is_active = TRUE; - -CREATE TRIGGER trg_disposition_routes_updated_at - BEFORE UPDATE ON disposition_routes - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -COMMIT; \ No newline at end of file diff --git a/migrations/000011_settings.down.sql b/migrations/000011_settings.down.sql deleted file mode 100644 index fb1ec1a..0000000 --- a/migrations/000011_settings.down.sql +++ /dev/null @@ -1,4 +0,0 @@ -BEGIN; -DROP TRIGGER IF EXISTS trg_app_settings_updated_at ON app_settings; -DROP TABLE IF EXISTS app_settings; -COMMIT; \ No newline at end of file diff --git a/migrations/000011_settings.up.sql b/migrations/000011_settings.up.sql deleted file mode 100644 index 91eee5b..0000000 --- a/migrations/000011_settings.up.sql +++ /dev/null @@ -1,21 +0,0 @@ -BEGIN; - -CREATE TABLE IF NOT EXISTS app_settings ( - key TEXT PRIMARY KEY, - value JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TRIGGER trg_app_settings_updated_at - BEFORE UPDATE ON app_settings - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -INSERT INTO app_settings(key, value) -VALUES - ('INCOMING_LETTER_PREFIX', '{"value": "ESLI"}'::jsonb), - ('INCOMING_LETTER_SEQUENCE', '{"value": 0}'::jsonb), - ('INCOMING_LETTER_RECIPIENTS', '{"department_codes": ["aslog"]}'::jsonb) -ON CONFLICT (key) DO NOTHING; - -COMMIT; \ No newline at end of file diff --git a/migrations/000012_voting_system.down.sql b/migrations/000012_voting_system.down.sql new file mode 100644 index 0000000..adaccff --- /dev/null +++ b/migrations/000012_voting_system.down.sql @@ -0,0 +1,5 @@ +-- Drop Voting System Tables + +DROP TABLE IF EXISTS votes; +DROP TABLE IF EXISTS candidates; +DROP TABLE IF EXISTS vote_events; \ No newline at end of file diff --git a/migrations/000012_voting_system.up.sql b/migrations/000012_voting_system.up.sql new file mode 100644 index 0000000..06b4e86 --- /dev/null +++ b/migrations/000012_voting_system.up.sql @@ -0,0 +1,51 @@ +-- Voting System Tables + +-- Vote Events Table +CREATE TABLE IF NOT EXISTS vote_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + description TEXT, + start_date TIMESTAMP WITHOUT TIME ZONE NOT NULL, + end_date TIMESTAMP WITHOUT TIME ZONE NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TRIGGER trg_vote_events_updated_at + BEFORE UPDATE ON vote_events + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Candidates Table +CREATE TABLE IF NOT EXISTS candidates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vote_event_id UUID NOT NULL REFERENCES vote_events(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + image_url VARCHAR(500), + description TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TRIGGER trg_candidates_updated_at + BEFORE UPDATE ON candidates + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX IF NOT EXISTS idx_candidates_vote_event_id ON candidates(vote_event_id); + +-- Votes Table +CREATE TABLE IF NOT EXISTS votes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vote_event_id UUID NOT NULL REFERENCES vote_events(id) ON DELETE CASCADE, + candidate_id UUID NOT NULL REFERENCES candidates(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Ensure one vote per user per event +CREATE UNIQUE INDEX IF NOT EXISTS uq_votes_user_event + ON votes(user_id, vote_event_id); + +CREATE INDEX IF NOT EXISTS idx_votes_vote_event_id ON votes(vote_event_id); +CREATE INDEX IF NOT EXISTS idx_votes_candidate_id ON votes(candidate_id); +CREATE INDEX IF NOT EXISTS idx_votes_user_id ON votes(user_id); \ No newline at end of file diff --git a/migrations/000013_add_results_open_to_vote_events.down.sql b/migrations/000013_add_results_open_to_vote_events.down.sql new file mode 100644 index 0000000..285761c --- /dev/null +++ b/migrations/000013_add_results_open_to_vote_events.down.sql @@ -0,0 +1,3 @@ +-- Remove results_open column from vote_events table +ALTER TABLE vote_events +DROP COLUMN IF EXISTS results_open; \ No newline at end of file diff --git a/migrations/000013_add_results_open_to_vote_events.up.sql b/migrations/000013_add_results_open_to_vote_events.up.sql new file mode 100644 index 0000000..29616a8 --- /dev/null +++ b/migrations/000013_add_results_open_to_vote_events.up.sql @@ -0,0 +1,3 @@ +-- Add results_open column to vote_events table +ALTER TABLE vote_events +ADD COLUMN results_open BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/server b/server new file mode 100755 index 0000000..66b7143 Binary files /dev/null and b/server differ