Compare commits

...

9 Commits
master ... main

Author SHA1 Message Date
Aditya Siregar
104cd987e9 Update UI result 2025-08-16 01:51:37 +07:00
Aditya Siregar
228f6a78c9 add deploment 2025-08-15 23:06:47 +07:00
Aditya Siregar
8322bbef18 Add users 2025-08-15 22:53:40 +07:00
Aditya Siregar
5966301165 add bulk users 2025-08-15 22:42:58 +07:00
Aditya Siregar
e1a5e9efd3 add users 2025-08-15 22:17:01 +07:00
Aditya Siregar
826c5d26ad Add init 2025-08-15 21:17:19 +07:00
Aditya Siregar
de60983e4e Init 2025-08-09 23:44:03 +07:00
Aditya Siregar
61d6eed373 letter incoming 2025-08-09 18:58:22 +07:00
Aditya Siregar
001d02c587 Add user Roles 2025-08-09 15:28:25 +07:00
82 changed files with 6420 additions and 276 deletions

130
MIGRATION_SUMMARY.md Normal file
View File

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

View File

@ -1,9 +1,9 @@
#PROJECT_NAME = "enaklo-pos-backend" #PROJECT_NAME = "enaklo-pos-backend"
DB_USERNAME :=eslogad_user DB_USERNAME :=metidb
DB_PASSWORD :=M9u%24e%23jT2%40qR4pX%21zL DB_PASSWORD :=metipassword%23123
DB_HOST :=103.191.71.2 DB_HOST :=103.191.71.2
DB_PORT :=5432 DB_PORT :=5433
DB_NAME :=eslogad_db DB_NAME :=mydb
DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable

41
config/db_optimized.yaml Normal file
View File

@ -0,0 +1,41 @@
# Optimized Database Configuration for handling 1000+ users
database:
host: localhost
port: 5432
db: meti_vote
driver: postgres
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
ssl-mode: disable
debug: false
# Connection Pool Settings - Optimized for high load
# For 1000+ concurrent users, these settings help manage database connections efficiently
# Maximum number of idle connections in the pool
# Keeping more idle connections reduces connection setup overhead
max-idle-connections-in-second: 25
# Maximum number of open connections to the database
# This prevents overwhelming the database with too many connections
max-open-connections-in-second: 100
# Maximum lifetime of a connection in seconds (30 minutes)
# This helps prevent stale connections and memory leaks
connection-max-life-time-in-second: 1800
# Additional PostgreSQL tuning recommendations:
#
# In postgresql.conf:
# - max_connections = 200
# - shared_buffers = 256MB
# - effective_cache_size = 1GB
# - work_mem = 4MB
# - maintenance_work_mem = 64MB
# - checkpoint_completion_target = 0.9
# - wal_buffers = 16MB
# - default_statistics_target = 100
# - random_page_cost = 1.1
# - effective_io_concurrency = 200
# - min_wal_size = 1GB
# - max_wal_size = 4GB

View File

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
APP_NAME="eslogad" APP_NAME="meti-backend"
PORT="4000" PORT="4000"
echo "🔄 Pulling latest code..." echo "🔄 Pulling latest code..."
@ -15,7 +15,7 @@ docker rm $APP_NAME 2>/dev/null
echo "🚀 Running new container..." echo "🚀 Running new container..."
docker run -d --name $APP_NAME \ docker run -d --name $APP_NAME \
-p $PORT:$PORT \ -p 4001:$PORT \
-v "$(pwd)/infra":/infra:ro \ -v "$(pwd)/infra":/infra:ro \
-v "$(pwd)/templates":/templates:ro \ -v "$(pwd)/templates":/templates:ro \
$APP_NAME:latest $APP_NAME:latest

4
go.mod
View File

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

6
go.sum
View File

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

View File

@ -10,11 +10,11 @@ jwt:
postgresql: postgresql:
host: 103.191.71.2 host: 103.191.71.2
port: 5432 port: 5433
driver: postgres driver: postgres
db: eslogad_db db: mydb
username: eslogad_user username: metidb
password: 'M9u$e#jT2@qR4pX!zL' password: 'metipassword#123'
ssl-mode: disable ssl-mode: disable
max-idle-connections-in-second: 600 max-idle-connections-in-second: 600
max-open-connections-in-second: 600 max-open-connections-in-second: 600

View File

@ -43,14 +43,24 @@ func (a *App) Initialize(cfg *config.Config) error {
middlewares := a.initMiddleware(services) middlewares := a.initMiddleware(services)
healthHandler := handler.NewHealthHandler() healthHandler := handler.NewHealthHandler()
fileHandler := handler.NewFileHandler(services.fileService) fileHandler := handler.NewFileHandler(services.fileService)
rbacHandler := handler.NewRBACHandler(services.rbacService)
masterHandler := handler.NewMasterHandler(services.masterService)
letterHandler := handler.NewLetterHandler(services.letterService)
dispositionRouteHandler := handler.NewDispositionRouteHandler(services.dispositionRouteService)
voteEventHandler := handler.NewVoteEventHandler(services.voteEventService)
a.router = router.NewRouter( a.router = router.NewRouter(
cfg, cfg,
handler.NewAuthHandler(services.authService), handler.NewAuthHandler(services.authService),
middlewares.authMiddleware, middlewares.authMiddleware,
healthHandler, healthHandler,
handler.NewUserHandler(services.userService, &validator.UserValidatorImpl{}), handler.NewUserHandler(services.userService, validator.NewUserValidator()),
fileHandler, fileHandler,
rbacHandler,
masterHandler,
letterHandler,
dispositionRouteHandler,
voteEventHandler,
) )
return nil return nil
@ -99,6 +109,26 @@ type repositories struct {
userRepo *repository.UserRepositoryImpl userRepo *repository.UserRepositoryImpl
userProfileRepo *repository.UserProfileRepository userProfileRepo *repository.UserProfileRepository
titleRepo *repository.TitleRepository titleRepo *repository.TitleRepository
rbacRepo *repository.RBACRepository
labelRepo *repository.LabelRepository
priorityRepo *repository.PriorityRepository
institutionRepo *repository.InstitutionRepository
dispRepo *repository.DispositionActionRepository
letterRepo *repository.LetterIncomingRepository
letterAttachRepo *repository.LetterIncomingAttachmentRepository
activityLogRepo *repository.LetterIncomingActivityLogRepository
dispositionRouteRepo *repository.DispositionRouteRepository
// new repos
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 { func (a *App) initRepositories() *repositories {
@ -106,16 +136,41 @@ func (a *App) initRepositories() *repositories {
userRepo: repository.NewUserRepository(a.db), userRepo: repository.NewUserRepository(a.db),
userProfileRepo: repository.NewUserProfileRepository(a.db), userProfileRepo: repository.NewUserProfileRepository(a.db),
titleRepo: repository.NewTitleRepository(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),
} }
} }
type processors struct { type processors struct {
userProcessor *processor.UserProcessorImpl userProcessor *processor.UserProcessorImpl
letterProcessor *processor.LetterProcessorImpl
activityLogger *processor.ActivityLogProcessorImpl
} }
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
txMgr := repository.NewTxManager(a.db)
activity := processor.NewActivityLogProcessor(repos.activityLogRepo)
return &processors{ return &processors{
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo), userProcessor: processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo),
letterProcessor: processor.NewLetterProcessor(repos.letterRepo, repos.letterAttachRepo, txMgr, activity, repos.letterDispositionRepo, repos.letterDispositionDeptRepo, repos.letterDispActionSelRepo, repos.dispositionNoteRepo, repos.letterDiscussionRepo, repos.settingRepo, repos.recipientRepo, repos.departmentRepo, repos.userDeptRepo, repos.priorityRepo, repos.institutionRepo, repos.dispRepo),
activityLogger: activity,
} }
} }
@ -123,6 +178,11 @@ type services struct {
userService *service.UserServiceImpl userService *service.UserServiceImpl
authService *service.AuthServiceImpl authService *service.AuthServiceImpl
fileService *service.FileServiceImpl fileService *service.FileServiceImpl
rbacService *service.RBACServiceImpl
masterService *service.MasterServiceImpl
letterService *service.LetterServiceImpl
dispositionRouteService *service.DispositionRouteServiceImpl
voteEventService *service.VoteEventServiceImpl
} }
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
@ -132,15 +192,27 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
userSvc := service.NewUserService(processors.userProcessor, repos.titleRepo) userSvc := service.NewUserService(processors.userProcessor, repos.titleRepo)
// File storage client and service
fileCfg := cfg.S3Config fileCfg := cfg.S3Config
s3Client := client.NewFileClient(fileCfg) s3Client := client.NewFileClient(fileCfg)
fileSvc := service.NewFileService(s3Client, processors.userProcessor, "profile", "documents") fileSvc := service.NewFileService(s3Client, processors.userProcessor, "profile", "documents")
rbacSvc := service.NewRBACService(repos.rbacRepo)
masterSvc := service.NewMasterService(repos.labelRepo, repos.priorityRepo, repos.institutionRepo, repos.dispRepo)
letterSvc := service.NewLetterService(processors.letterProcessor)
dispRouteSvc := service.NewDispositionRouteService(repos.dispositionRouteRepo)
voteEventSvc := service.NewVoteEventService(repos.voteEventRepo)
return &services{ return &services{
userService: userSvc, userService: userSvc,
authService: authService, authService: authService,
fileService: fileSvc, fileService: fileSvc,
rbacService: rbacSvc,
masterService: masterSvc,
letterService: letterSvc,
dispositionRouteService: dispRouteSvc,
voteEventService: voteEventSvc,
} }
} }

View File

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

View File

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

View File

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

View File

@ -2,6 +2,12 @@ package contract
import "time" import "time"
const (
SettingIncomingLetterPrefix = "INCOMING_LETTER_PREFIX"
SettingIncomingLetterSequence = "INCOMING_LETTER_SEQUENCE"
SettingIncomingLetterRecipients = "INCOMING_LETTER_RECIPIENTS"
)
type ErrorResponse struct { type ErrorResponse struct {
Error string `json:"error"` Error string `json:"error"`
Message string `json:"message"` Message string `json:"message"`
@ -47,3 +53,118 @@ type HealthResponse struct {
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
Version string `json:"version"` Version string `json:"version"`
} }
type LabelResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Color *string `json:"color,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateLabelRequest struct {
Name string `json:"name"`
Color *string `json:"color,omitempty"`
}
type UpdateLabelRequest struct {
Name *string `json:"name,omitempty"`
Color *string `json:"color,omitempty"`
}
type ListLabelsResponse struct {
Labels []LabelResponse `json:"labels"`
}
type PriorityResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Level int `json:"level"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreatePriorityRequest struct {
Name string `json:"name"`
Level int `json:"level"`
}
type UpdatePriorityRequest struct {
Name *string `json:"name,omitempty"`
Level *int `json:"level,omitempty"`
}
type ListPrioritiesResponse struct {
Priorities []PriorityResponse `json:"priorities"`
}
type InstitutionResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Address *string `json:"address,omitempty"`
ContactPerson *string `json:"contact_person,omitempty"`
Phone *string `json:"phone,omitempty"`
Email *string `json:"email,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateInstitutionRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Address *string `json:"address,omitempty"`
ContactPerson *string `json:"contact_person,omitempty"`
Phone *string `json:"phone,omitempty"`
Email *string `json:"email,omitempty"`
}
type UpdateInstitutionRequest struct {
Name *string `json:"name,omitempty"`
Type *string `json:"type,omitempty"`
Address *string `json:"address,omitempty"`
ContactPerson *string `json:"contact_person,omitempty"`
Phone *string `json:"phone,omitempty"`
Email *string `json:"email,omitempty"`
}
type ListInstitutionsResponse struct {
Institutions []InstitutionResponse `json:"institutions"`
}
type DispositionActionResponse struct {
ID string `json:"id"`
Code string `json:"code"`
Label string `json:"label"`
Description *string `json:"description,omitempty"`
RequiresNote bool `json:"requires_note"`
GroupName *string `json:"group_name,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateDispositionActionRequest struct {
Code string `json:"code"`
Label string `json:"label"`
Description *string `json:"description,omitempty"`
RequiresNote *bool `json:"requires_note,omitempty"`
GroupName *string `json:"group_name,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
type UpdateDispositionActionRequest struct {
Code *string `json:"code,omitempty"`
Label *string `json:"label,omitempty"`
Description *string `json:"description,omitempty"`
RequiresNote *bool `json:"requires_note,omitempty"`
GroupName *string `json:"group_name,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
type ListDispositionActionsResponse struct {
Actions []DispositionActionResponse `json:"actions"`
}

View File

@ -0,0 +1,43 @@
package contract
import (
"time"
"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"`
ToDepartmentID uuid.UUID `json:"to_department_id"`
IsActive bool `json:"is_active"`
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 {
FromDepartmentID uuid.UUID `json:"from_department_id"`
ToDepartmentID uuid.UUID `json:"to_department_id"`
IsActive *bool `json:"is_active,omitempty"`
AllowedActions *map[string]interface{} `json:"allowed_actions,omitempty"`
}
type UpdateDispositionRouteRequest struct {
IsActive *bool `json:"is_active,omitempty"`
AllowedActions *map[string]interface{} `json:"allowed_actions,omitempty"`
}
type ListDispositionRoutesResponse struct {
Routes []DispositionRouteResponse `json:"routes"`
}

View File

@ -0,0 +1,173 @@
package contract
import (
"time"
"github.com/google/uuid"
)
type CreateIncomingLetterAttachment struct {
FileURL string `json:"file_url"`
FileName string `json:"file_name"`
FileType string `json:"file_type"`
}
type CreateIncomingLetterRequest struct {
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"`
Attachments []CreateIncomingLetterAttachment `json:"attachments,omitempty"`
}
type IncomingLetterAttachmentResponse struct {
ID uuid.UUID `json:"id"`
FileURL string `json:"file_url"`
FileName string `json:"file_name"`
FileType string `json:"file_type"`
UploadedAt time.Time `json:"uploaded_at"`
}
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"`
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 {
ReferenceNumber *string `json:"reference_number,omitempty"`
Subject *string `json:"subject,omitempty"`
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,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
Status *string `json:"status,omitempty"`
}
type ListIncomingLettersRequest struct {
Page int `json:"page"`
Limit int `json:"limit"`
Status *string `json:"status,omitempty"`
Query *string `json:"query,omitempty"`
}
type ListIncomingLettersResponse struct {
Letters []IncomingLetterResponse `json:"letters"`
Pagination PaginationResponse `json:"pagination"`
}
type CreateDispositionActionSelection struct {
ActionID uuid.UUID `json:"action_id"`
Note *string `json:"note,omitempty"`
}
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"`
SelectedActions []CreateDispositionActionSelection `json:"selected_actions,omitempty"`
}
type DispositionResponse 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"`
}
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"`
Mentions map[string]interface{} `json:"mentions,omitempty"`
}
type UpdateLetterDiscussionRequest struct {
Message string `json:"message"`
Mentions map[string]interface{} `json:"mentions,omitempty"`
}
type LetterDiscussionResponse struct {
ID uuid.UUID `json:"id"`
LetterID uuid.UUID `json:"letter_id"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
UserID uuid.UUID `json:"user_id"`
Message string `json:"message"`
Mentions map[string]interface{} `json:"mentions,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
EditedAt *time.Time `json:"edited_at,omitempty"`
// Preloaded user profile who created the discussion
User *UserResponse `json:"user,omitempty"`
// Preloaded user profiles for mentions
MentionedUsers []UserResponse `json:"mentioned_users,omitempty"`
}

View File

@ -0,0 +1,57 @@
package contract
import (
"time"
"github.com/google/uuid"
)
type PermissionResponse struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Description *string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreatePermissionRequest struct {
Code string `json:"code"` // unique
Description *string `json:"description,omitempty"`
}
type UpdatePermissionRequest struct {
Code *string `json:"code,omitempty"`
Description *string `json:"description,omitempty"`
}
type ListPermissionsResponse struct {
Permissions []PermissionResponse `json:"permissions"`
}
type RoleWithPermissionsResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
Description *string `json:"description,omitempty"`
Permissions []PermissionResponse `json:"permissions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateRoleRequest struct {
Name string `json:"name"`
Code string `json:"code"`
Description *string `json:"description,omitempty"`
PermissionCodes []string `json:"permission_codes,omitempty"`
}
type UpdateRoleRequest struct {
Name *string `json:"name,omitempty"`
Code *string `json:"code,omitempty"`
Description *string `json:"description,omitempty"`
PermissionCodes *[]string `json:"permission_codes,omitempty"`
}
type ListRolesResponse struct {
Roles []RoleWithPermissionsResponse `json:"roles"`
}

View File

@ -44,16 +44,20 @@ type LoginResponse struct {
User UserResponse `json:"user"` User UserResponse `json:"user"`
Roles []RoleResponse `json:"roles"` Roles []RoleResponse `json:"roles"`
Permissions []string `json:"permissions"` Permissions []string `json:"permissions"`
Positions []PositionResponse `json:"positions"` Departments []DepartmentResponse `json:"departments"`
} }
type UserResponse struct { type UserResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Username string `json:"username"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Roles []RoleResponse `json:"roles,omitempty"`
DepartmentResponse []DepartmentResponse `json:"department_response"`
Profile *UserProfileResponse `json:"profile,omitempty"`
} }
type ListUsersRequest struct { type ListUsersRequest struct {
@ -61,6 +65,8 @@ type ListUsersRequest struct {
Limit int `json:"limit" validate:"min=1,max=100"` Limit int `json:"limit" validate:"min=1,max=100"`
Role *string `json:"role,omitempty"` Role *string `json:"role,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
Search *string `json:"search,omitempty"`
RoleCode *string `json:"role_code,omitempty"`
} }
type ListUsersResponse struct { type ListUsersResponse struct {
@ -74,7 +80,7 @@ type RoleResponse struct {
Code string `json:"code"` Code string `json:"code"`
} }
type PositionResponse struct { type DepartmentResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Code string `json:"code"` Code string `json:"code"`
@ -97,6 +103,7 @@ type UserProfileResponse struct {
LastSeenAt *time.Time `json:"last_seen_at,omitempty"` LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Roles []RoleResponse `json:"roles"`
} }
type UpdateUserProfileRequest struct { type UpdateUserProfileRequest struct {
@ -123,3 +130,55 @@ type TitleResponse struct {
type ListTitlesResponse struct { type ListTitlesResponse struct {
Titles []TitleResponse `json:"titles"` Titles []TitleResponse `json:"titles"`
} }
// MentionUsersRequest represents the request for getting users for mention purposes
type MentionUsersRequest struct {
Search *string `json:"search,omitempty" form:"search"` // Optional search term for username
Limit *int `json:"limit,omitempty" form:"limit"` // Optional limit, defaults to 50, max 100
}
// MentionUsersResponse represents the response for getting users for mention purposes
type MentionUsersResponse struct {
Users []UserResponse `json:"users"`
Count int `json:"count"`
}
// BulkCreateUsersRequest represents the request for creating multiple users
type BulkCreateUsersRequest struct {
Users []BulkUserRequest `json:"users" validate:"required,min=1,max=5000"`
}
// 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"`
}
// BulkCreateAsyncResponse represents the immediate response for async bulk creation
type BulkCreateAsyncResponse struct {
JobID uuid.UUID `json:"job_id"`
Message string `json:"message"`
Status string `json:"status"`
}

View File

@ -0,0 +1,118 @@
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"`
}
type VoteEventDetailsResponse struct {
VoteEvent VoteEventResponse `json:"vote_event"`
TotalParticipants int64 `json:"total_participants"`
TotalVoted int64 `json:"total_voted"`
TotalNotVoted int64 `json:"total_not_voted"`
}
type UserVoteInfo struct {
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
FullName string `json:"full_name"`
VotedAt time.Time `json:"voted_at"`
CandidateID uuid.UUID `json:"candidate_id"`
CandidateName string `json:"candidate_name"`
}
type UserBasicInfo struct {
UserID uuid.UUID `json:"user_id"`
Username string `json:"username"`
FullName string `json:"full_name"`
}

View File

@ -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"
}

View File

@ -0,0 +1,18 @@
package entities
import (
"time"
"github.com/google/uuid"
)
type Department struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Name string `gorm:"not null" json:"name"`
Code string `json:"code,omitempty"`
Path string `gorm:"not null" json:"path"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (Department) TableName() string { return "departments" }

View File

@ -0,0 +1,22 @@
package entities
import (
"time"
"github.com/google/uuid"
)
type DispositionAction struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Code string `gorm:"uniqueIndex;not null" json:"code"`
Label string `gorm:"not null" json:"label"`
Description *string `json:"description,omitempty"`
RequiresNote bool `gorm:"not null;default:false" json:"requires_note"`
GroupName *string `json:"group_name,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
IsActive bool `gorm:"not null;default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (DispositionAction) TableName() string { return "disposition_actions" }

View File

@ -0,0 +1,23 @@
package entities
import (
"time"
"github.com/google/uuid"
)
type DispositionRoute struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
FromDepartmentID uuid.UUID `gorm:"type:uuid;not null" json:"from_department_id"`
ToDepartmentID uuid.UUID `gorm:"type:uuid;not null" json:"to_department_id"`
IsActive bool `gorm:"not null;default:true" json:"is_active"`
AllowedActions JSONB `gorm:"type:jsonb" json:"allowed_actions,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
// Relationships
FromDepartment Department `gorm:"foreignKey:FromDepartmentID;references:ID" json:"from_department,omitempty"`
ToDepartment Department `gorm:"foreignKey:ToDepartmentID;references:ID" json:"to_department,omitempty"`
}
func (DispositionRoute) TableName() string { return "disposition_routes" }

View File

@ -0,0 +1,30 @@
package entities
import (
"time"
"github.com/google/uuid"
)
type InstitutionType string
const (
InstGovernment InstitutionType = "government"
InstPrivate InstitutionType = "private"
InstNGO InstitutionType = "ngo"
InstIndividual InstitutionType = "individual"
)
type Institution struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Name string `gorm:"not null;size:255" json:"name"`
Type InstitutionType `gorm:"not null;size:32" json:"type"`
Address *string `json:"address,omitempty"`
ContactPerson *string `gorm:"size:255" json:"contact_person,omitempty"`
Phone *string `gorm:"size:50" json:"phone,omitempty"`
Email *string `gorm:"size:255" json:"email,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (Institution) TableName() string { return "institutions" }

View File

@ -0,0 +1,17 @@
package entities
import (
"time"
"github.com/google/uuid"
)
type Label struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Name string `gorm:"not null;size:255" json:"name"`
Color *string `gorm:"size:16" json:"color,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (Label) TableName() string { return "labels" }

View File

@ -0,0 +1,24 @@
package entities
import (
"time"
"github.com/google/uuid"
)
type LetterDiscussion 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"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
Message string `gorm:"not null" json:"message"`
Mentions JSONB `gorm:"type:jsonb" json:"mentions,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
EditedAt *time.Time `json:"edited_at,omitempty"`
// Relationships
User *User `gorm:"foreignKey:UserID;references:ID" json:"user,omitempty"`
}
func (LetterDiscussion) TableName() string { return "letter_incoming_discussions" }

View File

@ -0,0 +1,65 @@
package entities
import (
"time"
"github.com/google/uuid"
)
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 (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"`
DispositionID uuid.UUID `gorm:"type:uuid;not null" json:"disposition_id"`
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" }
type LetterDispositionActionSelection struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
DispositionID uuid.UUID `gorm:"type:uuid;not null" json:"disposition_id"`
ActionID uuid.UUID `gorm:"type:uuid;not null" json:"action_id"`
Note *string `json:"note,omitempty"`
CreatedBy uuid.UUID `gorm:"not null" json:"created_by"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
// Relationships
Action *DispositionAction `gorm:"foreignKey:ActionID;references:ID" json:"action,omitempty"`
}
func (LetterDispositionActionSelection) TableName() string { return "letter_disposition_actions" }

View File

@ -0,0 +1,45 @@
package entities
import (
"time"
"github.com/google/uuid"
)
type LetterIncomingStatus string
const (
LetterIncomingStatusNew LetterIncomingStatus = "new"
LetterIncomingStatusInProgress LetterIncomingStatus = "in_progress"
LetterIncomingStatusCompleted LetterIncomingStatus = "completed"
)
type LetterIncoming struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
LetterNumber string `gorm:"uniqueIndex;not null" json:"letter_number"`
ReferenceNumber *string `json:"reference_number,omitempty"`
Subject string `gorm:"not null" 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 LetterIncomingStatus `gorm:"not null;default:'new'" json:"status"`
CreatedBy uuid.UUID `gorm:"not null" json:"created_by"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (LetterIncoming) TableName() string { return "letters_incoming" }
type LetterIncomingAttachment 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"`
FileURL string `gorm:"not null" json:"file_url"`
FileName string `gorm:"not null" json:"file_name"`
FileType string `gorm:"not null" json:"file_type"`
UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"`
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploaded_at"`
}
func (LetterIncomingAttachment) TableName() string { return "letter_incoming_attachments" }

View File

@ -0,0 +1,23 @@
package entities
import (
"time"
"github.com/google/uuid"
)
type LetterIncomingActivityLog 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"`
ActionType string `gorm:"not null" json:"action_type"`
ActorUserID *uuid.UUID `json:"actor_user_id,omitempty"`
ActorDepartmentID *uuid.UUID `json:"actor_department_id,omitempty"`
TargetType *string `json:"target_type,omitempty"`
TargetID *uuid.UUID `json:"target_id,omitempty"`
FromStatus *string `json:"from_status,omitempty"`
ToStatus *string `json:"to_status,omitempty"`
Context JSONB `gorm:"type:jsonb" json:"context,omitempty"`
OccurredAt time.Time `gorm:"autoCreateTime" json:"occurred_at"`
}
func (LetterIncomingActivityLog) TableName() string { return "letter_incoming_activity_logs" }

View File

@ -0,0 +1,28 @@
package entities
import (
"time"
"github.com/google/uuid"
)
type LetterIncomingRecipientStatus string
const (
RecipientStatusNew LetterIncomingRecipientStatus = "new"
RecipientStatusRead LetterIncomingRecipientStatus = "read"
RecipientStatusCompleted LetterIncomingRecipientStatus = "completed"
)
type LetterIncomingRecipient 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"`
RecipientUserID *uuid.UUID `json:"recipient_user_id,omitempty"`
RecipientDepartmentID *uuid.UUID `json:"recipient_department_id,omitempty"`
Status LetterIncomingRecipientStatus `gorm:"not null;default:'new'" json:"status"`
ReadAt *time.Time `json:"read_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (LetterIncomingRecipient) TableName() string { return "letter_incoming_recipients" }

View File

@ -0,0 +1,17 @@
package entities
import (
"time"
"github.com/google/uuid"
)
type Priority struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Name string `gorm:"not null;size:255" json:"name"`
Level int `gorm:"not null" json:"level"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (Priority) TableName() string { return "priorities" }

View File

@ -0,0 +1,10 @@
package entities
import "github.com/google/uuid"
type RolePermission struct {
RoleID uuid.UUID `gorm:"type:uuid;primaryKey" json:"role_id"`
PermissionID uuid.UUID `gorm:"type:uuid;primaryKey" json:"permission_id"`
}
func (RolePermission) TableName() string { return "role_permissions" }

View File

@ -0,0 +1,14 @@
package entities
import (
"time"
)
type AppSetting struct {
Key string `gorm:"primaryKey;size:100" json:"key"`
Value JSONB `gorm:"type:jsonb;default:'{}'" json:"value"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (AppSetting) TableName() string { return "app_settings" }

View File

@ -41,12 +41,15 @@ func (p *Permissions) Scan(value interface{}) error {
type User struct { type User struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Username string `gorm:"uniqueIndex;not null;size:100" json:"username" validate:"required,min=1,max=100"`
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"` Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"` Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"`
PasswordHash string `gorm:"not null;size:255" json:"-"` PasswordHash string `gorm:"not null;size:255" json:"-"`
IsActive bool `gorm:"default:true" json:"is_active"` IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Profile *UserProfile `gorm:"foreignKey:UserID;references:ID" json:"profile,omitempty"`
Departments []Department `gorm:"many2many:user_department;foreignKey:ID;joinForeignKey:user_id;References:ID;joinReferences:department_id" json:"departments,omitempty"`
} }
func (u *User) BeforeCreate(tx *gorm.DB) error { func (u *User) BeforeCreate(tx *gorm.DB) error {

30
internal/entities/vote.go Normal file
View File

@ -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"
}

View File

@ -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)
}

View File

@ -0,0 +1,98 @@
package handler
import (
"context"
"eslogad-be/internal/appcontext"
"eslogad-be/internal/contract"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type DispositionRouteService interface {
Create(ctx context.Context, req *contract.CreateDispositionRouteRequest) (*contract.DispositionRouteResponse, error)
Update(ctx context.Context, id uuid.UUID, req *contract.UpdateDispositionRouteRequest) (*contract.DispositionRouteResponse, error)
Get(ctx context.Context, id uuid.UUID) (*contract.DispositionRouteResponse, error)
ListByFromDept(ctx context.Context, from uuid.UUID) (*contract.ListDispositionRoutesResponse, error)
SetActive(ctx context.Context, id uuid.UUID, active bool) error
}
type DispositionRouteHandler struct{ svc DispositionRouteService }
func NewDispositionRouteHandler(svc DispositionRouteService) *DispositionRouteHandler {
return &DispositionRouteHandler{svc: svc}
}
func (h *DispositionRouteHandler) Create(c *gin.Context) {
var req contract.CreateDispositionRouteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.Create(c.Request.Context(), &req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(201, contract.BuildSuccessResponse(resp))
}
func (h *DispositionRouteHandler) Update(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
var req contract.UpdateDispositionRouteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.Update(c.Request.Context(), id, &req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}
func (h *DispositionRouteHandler) Get(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
resp, err := h.svc.Get(c.Request.Context(), id)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}
func (h *DispositionRouteHandler) ListByFromDept(c *gin.Context) {
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
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}
func (h *DispositionRouteHandler) SetActive(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
toggle := c.Query("active")
active := toggle != "false"
if err := h.svc.SetActive(c.Request.Context(), id, active); err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, &contract.SuccessResponse{Message: "updated"})
}

View File

@ -0,0 +1,186 @@
package handler
import (
"context"
"eslogad-be/internal/appcontext"
"net/http"
"strconv"
"eslogad-be/internal/contract"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type LetterService interface {
CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error)
GetIncomingLetterByID(ctx context.Context, id uuid.UUID) (*contract.IncomingLetterResponse, error)
ListIncomingLetters(ctx context.Context, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error)
UpdateIncomingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateIncomingLetterRequest) (*contract.IncomingLetterResponse, error)
SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error
CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*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)
}
type LetterHandler struct{ svc LetterService }
func NewLetterHandler(svc LetterService) *LetterHandler { return &LetterHandler{svc: svc} }
func (h *LetterHandler) CreateIncomingLetter(c *gin.Context) {
var req contract.CreateIncomingLetterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest})
return
}
resp, err := h.svc.CreateIncomingLetter(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp))
}
func (h *LetterHandler) GetIncomingLetter(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
resp, err := h.svc.GetIncomingLetterByID(c.Request.Context(), id)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}
func (h *LetterHandler) ListIncomingLetters(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
status := c.Query("status")
query := c.Query("q")
var statusPtr *string
var queryPtr *string
if status != "" {
statusPtr = &status
}
if query != "" {
queryPtr = &query
}
req := &contract.ListIncomingLettersRequest{Page: page, Limit: limit, Status: statusPtr, Query: queryPtr}
resp, err := h.svc.ListIncomingLetters(c.Request.Context(), req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}
func (h *LetterHandler) UpdateIncomingLetter(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
var req contract.UpdateIncomingLetterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.UpdateIncomingLetter(c.Request.Context(), id, &req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}
func (h *LetterHandler) DeleteIncomingLetter(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
if err := h.svc.SoftDeleteIncomingLetter(c.Request.Context(), id); err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, &contract.SuccessResponse{Message: "deleted"})
}
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})
return
}
c.JSON(201, contract.BuildSuccessResponse(resp))
}
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.GetEnhancedDispositionsByLetter(c.Request.Context(), letterID)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}
func (h *LetterHandler) CreateDiscussion(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
}
var req contract.CreateLetterDiscussionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.CreateDiscussion(c.Request.Context(), letterID, &req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(201, contract.BuildSuccessResponse(resp))
}
func (h *LetterHandler) UpdateDiscussion(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
}
discussionID, err := uuid.Parse(c.Param("discussion_id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid discussion_id", Code: 400})
return
}
var req contract.UpdateLetterDiscussionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.UpdateDiscussion(c.Request.Context(), letterID, discussionID, &req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}

View File

@ -0,0 +1,252 @@
package handler
import (
"context"
"net/http"
"eslogad-be/internal/contract"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type MasterService interface {
CreateLabel(ctx context.Context, req *contract.CreateLabelRequest) (*contract.LabelResponse, error)
UpdateLabel(ctx context.Context, id uuid.UUID, req *contract.UpdateLabelRequest) (*contract.LabelResponse, error)
DeleteLabel(ctx context.Context, id uuid.UUID) error
ListLabels(ctx context.Context) (*contract.ListLabelsResponse, error)
CreatePriority(ctx context.Context, req *contract.CreatePriorityRequest) (*contract.PriorityResponse, error)
UpdatePriority(ctx context.Context, id uuid.UUID, req *contract.UpdatePriorityRequest) (*contract.PriorityResponse, error)
DeletePriority(ctx context.Context, id uuid.UUID) error
ListPriorities(ctx context.Context) (*contract.ListPrioritiesResponse, error)
CreateInstitution(ctx context.Context, req *contract.CreateInstitutionRequest) (*contract.InstitutionResponse, error)
UpdateInstitution(ctx context.Context, id uuid.UUID, req *contract.UpdateInstitutionRequest) (*contract.InstitutionResponse, error)
DeleteInstitution(ctx context.Context, id uuid.UUID) error
ListInstitutions(ctx context.Context) (*contract.ListInstitutionsResponse, error)
CreateDispositionAction(ctx context.Context, req *contract.CreateDispositionActionRequest) (*contract.DispositionActionResponse, error)
UpdateDispositionAction(ctx context.Context, id uuid.UUID, req *contract.UpdateDispositionActionRequest) (*contract.DispositionActionResponse, error)
DeleteDispositionAction(ctx context.Context, id uuid.UUID) error
ListDispositionActions(ctx context.Context) (*contract.ListDispositionActionsResponse, error)
}
type MasterHandler struct{ svc MasterService }
func NewMasterHandler(svc MasterService) *MasterHandler { return &MasterHandler{svc: svc} }
func (h *MasterHandler) CreateLabel(c *gin.Context) {
var req contract.CreateLabelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.CreateLabel(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp))
}
func (h *MasterHandler) UpdateLabel(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
var req contract.UpdateLabelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.UpdateLabel(c.Request.Context(), id, &req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}
func (h *MasterHandler) DeleteLabel(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
if err := h.svc.DeleteLabel(c.Request.Context(), id); err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, &contract.SuccessResponse{Message: "deleted"})
}
func (h *MasterHandler) ListLabels(c *gin.Context) {
resp, err := h.svc.ListLabels(c.Request.Context())
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}
// Priorities
func (h *MasterHandler) CreatePriority(c *gin.Context) {
var req contract.CreatePriorityRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.CreatePriority(c.Request.Context(), &req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(201, contract.BuildSuccessResponse(resp))
}
func (h *MasterHandler) UpdatePriority(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
var req contract.UpdatePriorityRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.UpdatePriority(c.Request.Context(), id, &req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}
func (h *MasterHandler) DeletePriority(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
if err := h.svc.DeletePriority(c.Request.Context(), id); err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, &contract.SuccessResponse{Message: "deleted"})
}
func (h *MasterHandler) ListPriorities(c *gin.Context) {
resp, err := h.svc.ListPriorities(c.Request.Context())
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}
// Institutions
func (h *MasterHandler) CreateInstitution(c *gin.Context) {
var req contract.CreateInstitutionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.CreateInstitution(c.Request.Context(), &req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(201, contract.BuildSuccessResponse(resp))
}
func (h *MasterHandler) UpdateInstitution(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
var req contract.UpdateInstitutionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.UpdateInstitution(c.Request.Context(), id, &req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}
func (h *MasterHandler) DeleteInstitution(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
if err := h.svc.DeleteInstitution(c.Request.Context(), id); err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, &contract.SuccessResponse{Message: "deleted"})
}
func (h *MasterHandler) ListInstitutions(c *gin.Context) {
resp, err := h.svc.ListInstitutions(c.Request.Context())
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}
// Disposition Actions
func (h *MasterHandler) CreateDispositionAction(c *gin.Context) {
var req contract.CreateDispositionActionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.CreateDispositionAction(c.Request.Context(), &req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(201, contract.BuildSuccessResponse(resp))
}
func (h *MasterHandler) UpdateDispositionAction(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
var req contract.UpdateDispositionActionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.UpdateDispositionAction(c.Request.Context(), id, &req)
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}
func (h *MasterHandler) DeleteDispositionAction(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
if err := h.svc.DeleteDispositionAction(c.Request.Context(), id); err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, &contract.SuccessResponse{Message: "deleted"})
}
func (h *MasterHandler) ListDispositionActions(c *gin.Context) {
resp, err := h.svc.ListDispositionActions(c.Request.Context())
if err != nil {
c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(200, contract.BuildSuccessResponse(resp))
}

View File

@ -0,0 +1,137 @@
package handler
import (
"context"
"net/http"
"eslogad-be/internal/contract"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type RBACService interface {
CreatePermission(ctx context.Context, req *contract.CreatePermissionRequest) (*contract.PermissionResponse, error)
UpdatePermission(ctx context.Context, id uuid.UUID, req *contract.UpdatePermissionRequest) (*contract.PermissionResponse, error)
DeletePermission(ctx context.Context, id uuid.UUID) error
ListPermissions(ctx context.Context) (*contract.ListPermissionsResponse, error)
CreateRole(ctx context.Context, req *contract.CreateRoleRequest) (*contract.RoleWithPermissionsResponse, error)
UpdateRole(ctx context.Context, id uuid.UUID, req *contract.UpdateRoleRequest) (*contract.RoleWithPermissionsResponse, error)
DeleteRole(ctx context.Context, id uuid.UUID) error
ListRoles(ctx context.Context) (*contract.ListRolesResponse, error)
}
type RBACHandler struct{ svc RBACService }
func NewRBACHandler(svc RBACService) *RBACHandler { return &RBACHandler{svc: svc} }
func (h *RBACHandler) CreatePermission(c *gin.Context) {
var req contract.CreatePermissionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest})
return
}
resp, err := h.svc.CreatePermission(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp))
}
func (h *RBACHandler) UpdatePermission(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
var req contract.UpdatePermissionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.UpdatePermission(c.Request.Context(), id, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}
func (h *RBACHandler) DeletePermission(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
if err := h.svc.DeletePermission(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "deleted"})
}
func (h *RBACHandler) ListPermissions(c *gin.Context) {
resp, err := h.svc.ListPermissions(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}
func (h *RBACHandler) CreateRole(c *gin.Context) {
var req contract.CreateRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.CreateRole(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp))
}
func (h *RBACHandler) UpdateRole(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
var req contract.UpdateRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400})
return
}
resp, err := h.svc.UpdateRole(c.Request.Context(), id, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}
func (h *RBACHandler) DeleteRole(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400})
return
}
if err := h.svc.DeleteRole(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "deleted"})
}
func (h *RBACHandler) ListRoles(c *gin.Context) {
resp, err := h.svc.ListRoles(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500})
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}

View File

@ -1,8 +1,10 @@
package handler package handler
import ( import (
"context"
"net/http" "net/http"
"strconv" "strconv"
"time"
"eslogad-be/internal/appcontext" "eslogad-be/internal/appcontext"
"eslogad-be/internal/constants" "eslogad-be/internal/constants"
@ -51,6 +53,102 @@ func (h *UserHandler) CreateUser(c *gin.Context) {
c.JSON(http.StatusCreated, userResponse) 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) > 5000 {
h.sendValidationErrorResponse(c, "Cannot create more than 5000 users at once", constants.MissingFieldErrorCode)
return
}
ctx := c.Request.Context()
if len(req.Users) > 500 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
}
logger.FromContext(c).Infof("UserHandler::BulkCreateUsers -> Starting bulk creation of %d users", len(req.Users))
response, err := h.userService.BulkCreateUsers(ctx, &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) BulkCreateUsersAsync(c *gin.Context) {
var req contract.BulkCreateUsersRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::BulkCreateUsersAsync -> 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) > 5000 {
h.sendValidationErrorResponse(c, "Cannot create more than 5000 users at once", constants.MissingFieldErrorCode)
return
}
logger.FromContext(c).Infof("UserHandler::BulkCreateUsersAsync -> Starting async bulk creation of %d users", len(req.Users))
response, err := h.userService.BulkCreateUsersAsync(c.Request.Context(), &req)
if err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::BulkCreateUsersAsync -> Failed to start async bulk creation")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
logger.FromContext(c).Infof("UserHandler::BulkCreateUsersAsync -> Job created with ID: %s", response.JobID)
c.JSON(http.StatusAccepted, contract.BuildSuccessResponse(response))
}
func (h *UserHandler) GetBulkJobStatus(c *gin.Context) {
jobIDStr := c.Param("jobId")
jobID, err := uuid.Parse(jobIDStr)
if err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::GetBulkJobStatus -> invalid job ID")
h.sendValidationErrorResponse(c, "Invalid job ID", constants.ValidationErrorCode)
return
}
job, err := h.userService.GetBulkJobStatus(c.Request.Context(), jobID)
if err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::GetBulkJobStatus -> Failed to get job status")
h.sendErrorResponse(c, err.Error(), http.StatusNotFound)
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(job))
}
func (h *UserHandler) UpdateUser(c *gin.Context) { func (h *UserHandler) UpdateUser(c *gin.Context) {
userIDStr := c.Param("id") userIDStr := c.Param("id")
userID, err := uuid.Parse(userIDStr) userID, err := uuid.Parse(userIDStr)
@ -166,10 +264,24 @@ func (h *UserHandler) ListUsers(c *gin.Context) {
} }
} }
var roleParam *string
if role := c.Query("role"); role != "" { if role := c.Query("role"); role != "" {
roleParam = &role
req.Role = &role req.Role = &role
} }
if roleCode := c.Query("role_code"); roleCode != "" {
req.RoleCode = &roleCode
}
if req.RoleCode == nil && roleParam != nil {
req.RoleCode = roleParam
}
if search := c.Query("search"); search != "" {
req.Search = &search
}
if isActiveStr := c.Query("is_active"); isActiveStr != "" { if isActiveStr := c.Query("is_active"); isActiveStr != "" {
if isActive, err := strconv.ParseBool(isActiveStr); err == nil { if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
req.IsActive = &isActive req.IsActive = &isActive
@ -271,12 +383,48 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
} }
func (h *UserHandler) ListTitles(c *gin.Context) { func (h *UserHandler) ListTitles(c *gin.Context) {
resp, err := h.userService.ListTitles(c.Request.Context()) titles, err := h.userService.ListTitles(c.Request.Context())
if err != nil { if err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::ListTitles -> Failed to get titles from service")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return return
} }
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
logger.FromContext(c).Infof("UserHandler::ListTitles -> Successfully retrieved titles = %+v", titles)
c.JSON(http.StatusOK, titles)
}
func (h *UserHandler) GetActiveUsersForMention(c *gin.Context) {
search := c.Query("search")
limitStr := c.DefaultQuery("limit", "50")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 50
}
if limit > 100 {
limit = 100
}
var searchPtr *string
if search != "" {
searchPtr = &search
}
users, err := h.userService.GetActiveUsersForMention(c.Request.Context(), searchPtr, limit)
if err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::GetActiveUsersForMention -> Failed to get active users from service")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
response := contract.MentionUsersResponse{
Users: users,
Count: len(users),
}
logger.FromContext(c).Infof("UserHandler::GetActiveUsersForMention -> Successfully retrieved %d active users", len(users))
c.JSON(http.StatusOK, response)
} }
func (h *UserHandler) sendErrorResponse(c *gin.Context, message string, statusCode int) { func (h *UserHandler) sendErrorResponse(c *gin.Context, message string, statusCode int) {

View File

@ -3,12 +3,16 @@ package handler
import ( import (
"context" "context"
"eslogad-be/internal/contract" "eslogad-be/internal/contract"
"eslogad-be/internal/manager"
"github.com/google/uuid" "github.com/google/uuid"
) )
type UserService interface { type UserService interface {
CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error) CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error)
BulkCreateUsers(ctx context.Context, req *contract.BulkCreateUsersRequest) (*contract.BulkCreateUsersResponse, error)
BulkCreateUsersAsync(ctx context.Context, req *contract.BulkCreateUsersRequest) (*contract.BulkCreateAsyncResponse, error)
GetBulkJobStatus(ctx context.Context, jobID uuid.UUID) (*manager.BulkJobResult, error)
UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error)
DeleteUser(ctx context.Context, id uuid.UUID) error DeleteUser(ctx context.Context, id uuid.UUID) error
GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error)
@ -20,4 +24,6 @@ type UserService interface {
UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error)
ListTitles(ctx context.Context) (*contract.ListTitlesResponse, error) ListTitles(ctx context.Context) (*contract.ListTitlesResponse, error)
GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error)
} }

View File

@ -0,0 +1,344 @@
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)
GetVoteEventDetails(ctx context.Context, eventID uuid.UUID) (*contract.VoteEventDetailsResponse, 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) GetVoteEventDetails(c *gin.Context) {
eventIDStr := c.Param("id")
eventID, err := uuid.Parse(eventIDStr)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetVoteEventDetails -> Invalid event ID")
h.sendValidationErrorResponse(c, "Invalid event ID", constants.MalformedFieldErrorCode)
return
}
details, err := h.voteEventService.GetVoteEventDetails(c.Request.Context(), eventID)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetVoteEventDetails -> Failed to get vote event details")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
logger.FromContext(c).Infof("VoteEventHandler::GetVoteEventDetails -> Successfully retrieved vote event details")
c.JSON(http.StatusOK, contract.BuildSuccessResponse(details))
}
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)
}

View File

@ -0,0 +1,117 @@
package manager
import (
"context"
"sync"
"time"
"eslogad-be/internal/contract"
"github.com/google/uuid"
)
type JobStatus string
const (
JobStatusPending JobStatus = "pending"
JobStatusProcessing JobStatus = "processing"
JobStatusCompleted JobStatus = "completed"
JobStatusFailed JobStatus = "failed"
)
type BulkJobResult struct {
JobID uuid.UUID `json:"job_id"`
Status JobStatus `json:"status"`
Message string `json:"message"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Summary contract.BulkCreationSummary `json:"summary"`
Created []contract.UserResponse `json:"created"`
Failed []contract.BulkUserErrorResult `json:"failed"`
}
type JobManager struct {
jobs sync.Map
}
var jobManagerInstance *JobManager
var once sync.Once
func GetJobManager() *JobManager {
once.Do(func() {
jobManagerInstance = &JobManager{}
})
return jobManagerInstance
}
func (jm *JobManager) CreateJob() uuid.UUID {
jobID := uuid.New()
job := &BulkJobResult{
JobID: jobID,
Status: JobStatusPending,
Message: "Job created, waiting to start",
StartedAt: time.Now(),
Summary: contract.BulkCreationSummary{
Total: 0,
Succeeded: 0,
Failed: 0,
},
Created: []contract.UserResponse{},
Failed: []contract.BulkUserErrorResult{},
}
jm.jobs.Store(jobID, job)
return jobID
}
func (jm *JobManager) UpdateJob(jobID uuid.UUID, status JobStatus, message string) {
if val, ok := jm.jobs.Load(jobID); ok {
job := val.(*BulkJobResult)
job.Status = status
job.Message = message
if status == JobStatusCompleted || status == JobStatusFailed {
now := time.Now()
job.FinishedAt = &now
}
jm.jobs.Store(jobID, job)
}
}
func (jm *JobManager) UpdateJobResults(jobID uuid.UUID, created []contract.UserResponse, failed []contract.BulkUserErrorResult, summary contract.BulkCreationSummary) {
if val, ok := jm.jobs.Load(jobID); ok {
job := val.(*BulkJobResult)
job.Created = append(job.Created, created...)
job.Failed = append(job.Failed, failed...)
job.Summary.Total = summary.Total
job.Summary.Succeeded += summary.Succeeded
job.Summary.Failed += summary.Failed
jm.jobs.Store(jobID, job)
}
}
func (jm *JobManager) GetJob(jobID uuid.UUID) (*BulkJobResult, bool) {
if val, ok := jm.jobs.Load(jobID); ok {
return val.(*BulkJobResult), true
}
return nil, false
}
func (jm *JobManager) CleanupOldJobs(ctx context.Context, maxAge time.Duration) {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cutoff := time.Now().Add(-maxAge)
jm.jobs.Range(func(key, value interface{}) bool {
job := value.(*BulkJobResult)
if job.FinishedAt != nil && job.FinishedAt.Before(cutoff) {
jm.jobs.Delete(key)
}
return true
})
}
}
}

View File

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

View File

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

View File

@ -5,11 +5,13 @@ import (
) )
func CORS() gin.HandlerFunc { 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-Origin", "*")
c.Header("Access-Control-Allow-Credentials", "true") 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-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") 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" { if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204) c.AbortWithStatus(204)
@ -17,5 +19,5 @@ func CORS() gin.HandlerFunc {
} }
c.Next() c.Next()
}) }
} }

View File

@ -0,0 +1,37 @@
package processor
import (
"context"
"eslogad-be/internal/entities"
"eslogad-be/internal/repository"
"github.com/google/uuid"
)
type ActivityLogProcessorImpl struct {
repo *repository.LetterIncomingActivityLogRepository
}
func NewActivityLogProcessor(repo *repository.LetterIncomingActivityLogRepository) *ActivityLogProcessorImpl {
return &ActivityLogProcessorImpl{repo: repo}
}
func (p *ActivityLogProcessorImpl) Log(ctx context.Context, letterID uuid.UUID, actionType string, actorUserID *uuid.UUID, actorDepartmentID *uuid.UUID, targetType *string, targetID *uuid.UUID, fromStatus *string, toStatus *string, contextData map[string]interface{}) error {
ctxJSON := entities.JSONB{}
for k, v := range contextData {
ctxJSON[k] = v
}
entry := &entities.LetterIncomingActivityLog{
LetterID: letterID,
ActionType: actionType,
ActorUserID: actorUserID,
ActorDepartmentID: actorDepartmentID,
TargetType: targetType,
TargetID: targetID,
FromStatus: fromStatus,
ToStatus: toStatus,
Context: ctxJSON,
}
return p.repo.Create(ctx, entry)
}

View File

@ -0,0 +1,492 @@
package processor
import (
"context"
"fmt"
"time"
"eslogad-be/internal/appcontext"
"eslogad-be/internal/contract"
"eslogad-be/internal/entities"
"eslogad-be/internal/repository"
"eslogad-be/internal/transformer"
"github.com/google/uuid"
)
type LetterProcessorImpl struct {
letterRepo *repository.LetterIncomingRepository
attachRepo *repository.LetterIncomingAttachmentRepository
txManager *repository.TxManager
activity *ActivityLogProcessorImpl
dispositionRepo *repository.LetterIncomingDispositionRepository
dispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository
dispositionActionSelRepo *repository.LetterDispositionActionSelectionRepository
dispositionNoteRepo *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
}
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) {
var result *contract.IncomingLetterResponse
err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
userID := appcontext.FromGinContext(txCtx).UserID
prefix := "ESLI"
seq := 0
if s, err := p.settingRepo.Get(txCtx, contract.SettingIncomingLetterPrefix); err == nil {
if v, ok := s.Value["value"].(string); ok && v != "" {
prefix = v
}
}
if s, err := p.settingRepo.Get(txCtx, contract.SettingIncomingLetterSequence); err == nil {
if v, ok := s.Value["value"].(float64); ok {
seq = int(v)
}
}
seq = seq + 1
letterNumber := fmt.Sprintf("%s%04d", prefix, seq)
entity := &entities.LetterIncoming{
ReferenceNumber: req.ReferenceNumber,
Subject: req.Subject,
Description: req.Description,
PriorityID: req.PriorityID,
SenderInstitutionID: req.SenderInstitutionID,
ReceivedDate: req.ReceivedDate,
DueDate: req.DueDate,
Status: entities.LetterIncomingStatusNew,
CreatedBy: userID,
}
entity.LetterNumber = letterNumber
if err := p.letterRepo.Create(txCtx, entity); err != nil {
return err
}
_ = p.settingRepo.Upsert(txCtx, contract.SettingIncomingLetterSequence, entities.JSONB{"value": seq})
defaultDeptCodes := []string{}
if s, err := p.settingRepo.Get(txCtx, contract.SettingIncomingLetterRecipients); err == nil {
if arr, ok := s.Value["department_codes"].([]interface{}); ok {
for _, it := range arr {
if str, ok := it.(string); ok {
defaultDeptCodes = append(defaultDeptCodes, str)
}
}
}
}
depIDs := make([]uuid.UUID, 0, len(defaultDeptCodes))
for _, code := range defaultDeptCodes {
dep, err := p.departmentRepo.GetByCode(txCtx, code)
if err != nil {
continue
}
depIDs = append(depIDs, dep.ID)
}
userMemberships, _ := p.userDeptRepo.ListActiveByDepartmentIDs(txCtx, depIDs)
var recipients []entities.LetterIncomingRecipient
mapsUsers := map[string]bool{}
for _, row := range userMemberships {
uid := row.UserID
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
}
}
if p.activity != nil {
action := "letter.created"
if err := p.activity.Log(txCtx, entity.ID, action, &userID, nil, nil, nil, nil, nil, map[string]interface{}{"letter_number": letterNumber}); err != nil {
return err
}
}
attachments := make([]entities.LetterIncomingAttachment, 0, len(req.Attachments))
for _, a := range req.Attachments {
attachments = append(attachments, entities.LetterIncomingAttachment{LetterID: entity.ID, FileURL: a.FileURL, FileName: a.FileName, FileType: a.FileType, UploadedBy: &userID})
}
if len(attachments) > 0 {
if err := p.attachRepo.CreateBulk(txCtx, attachments); err != nil {
return err
}
if p.activity != nil {
action := "attachment.uploaded"
for _, a := range attachments {
ctxMap := map[string]interface{}{"file_name": a.FileName, "file_type": a.FileType}
if err := p.activity.Log(txCtx, entity.ID, action, &userID, nil, nil, nil, nil, nil, ctxMap); err != nil {
return err
}
}
}
}
savedAttachments, _ := p.attachRepo.ListByLetter(txCtx, entity.ID)
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
}
return result, nil
}
func (p *LetterProcessorImpl) GetIncomingLetterByID(ctx context.Context, id uuid.UUID) (*contract.IncomingLetterResponse, error) {
entity, err := p.letterRepo.Get(ctx, id)
if err != nil {
return nil, err
}
atts, _ := p.attachRepo.ListByLetter(ctx, id)
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) {
page, limit := req.Page, req.Limit
if page <= 0 {
page = 1
}
if limit <= 0 {
limit = 10
}
filter := repository.ListIncomingLettersFilter{Status: req.Status, Query: req.Query}
list, total, err := p.letterRepo.List(ctx, filter, limit, (page-1)*limit)
if err != nil {
return nil, err
}
respList := make([]contract.IncomingLetterResponse, 0, len(list))
for _, e := range list {
atts, _ := p.attachRepo.ListByLetter(ctx, e.ID)
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
}
func (p *LetterProcessorImpl) UpdateIncomingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) {
var out *contract.IncomingLetterResponse
err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
entity, err := p.letterRepo.Get(txCtx, id)
if err != nil {
return err
}
fromStatus := string(entity.Status)
if req.ReferenceNumber != nil {
entity.ReferenceNumber = req.ReferenceNumber
}
if req.Subject != nil {
entity.Subject = *req.Subject
}
if req.Description != nil {
entity.Description = req.Description
}
if req.PriorityID != nil {
entity.PriorityID = req.PriorityID
}
if req.SenderInstitutionID != nil {
entity.SenderInstitutionID = req.SenderInstitutionID
}
if req.ReceivedDate != nil {
entity.ReceivedDate = *req.ReceivedDate
}
if req.DueDate != nil {
entity.DueDate = req.DueDate
}
if req.Status != nil {
entity.Status = entities.LetterIncomingStatus(*req.Status)
}
if err := p.letterRepo.Update(txCtx, entity); err != nil {
return err
}
toStatus := string(entity.Status)
if p.activity != nil && fromStatus != toStatus {
userID := appcontext.FromGinContext(txCtx).UserID
action := "status.changed"
if err := p.activity.Log(txCtx, id, action, &userID, nil, nil, nil, &fromStatus, &toStatus, map[string]interface{}{}); err != nil {
return err
}
}
atts, _ := p.attachRepo.ListByLetter(txCtx, id)
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 {
return nil, err
}
return out, nil
}
func (p *LetterProcessorImpl) SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error {
return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
if err := p.letterRepo.SoftDelete(txCtx, id); err != nil {
return err
}
if p.activity != nil {
userID := appcontext.FromGinContext(txCtx).UserID
action := "letter.deleted"
if err := p.activity.Log(txCtx, id, action, &userID, nil, nil, nil, nil, nil, map[string]interface{}{}); err != nil {
return err
}
}
return nil
})
}
func (p *LetterProcessorImpl) CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) {
var out *contract.ListDispositionsResponse
err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
userID := appcontext.FromGinContext(txCtx).UserID
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 {
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.dispositionActionSelRepo.CreateBulk(txCtx, selections); err != nil {
return err
}
}
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 {
return nil, err
}
return out, nil
}
func (p *LetterProcessorImpl) ListDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListDispositionsResponse, error) {
list, err := p.dispositionRepo.ListByLetter(ctx, letterID)
if err != nil {
return nil, err
}
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 {
userID := appcontext.FromGinContext(txCtx).UserID
mentions := entities.JSONB(nil)
if req.Mentions != nil {
mentions = entities.JSONB(req.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
}
if p.activity != nil {
action := "reference_numberdiscussion.created"
tgt := "discussion"
ctxMap := map[string]interface{}{"message": req.Message, "parent_id": req.ParentID}
if err := p.activity.Log(txCtx, letterID, action, &userID, nil, &tgt, &disc.ID, nil, nil, ctxMap); err != nil {
return err
}
}
out = transformer.DiscussionEntityToContract(disc)
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
func (p *LetterProcessorImpl) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) {
var out *contract.LetterDiscussionResponse
err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
disc, err := p.discussionRepo.Get(txCtx, discussionID)
if err != nil {
return err
}
oldMessage := disc.Message
disc.Message = req.Message
if req.Mentions != nil {
disc.Mentions = entities.JSONB(req.Mentions)
}
now := time.Now()
disc.EditedAt = &now
if err := p.discussionRepo.Update(txCtx, disc); err != nil {
return err
}
if p.activity != nil {
userID := appcontext.FromGinContext(txCtx).UserID
action := "discussion.updated"
tgt := "discussion"
ctxMap := map[string]interface{}{"old_message": oldMessage, "new_message": req.Message}
if err := p.activity.Log(txCtx, letterID, action, &userID, nil, &tgt, &disc.ID, nil, nil, ctxMap); err != nil {
return err
}
}
out = transformer.DiscussionEntityToContract(disc)
return nil
})
if err != nil {
return nil, err
}
return out, nil
}

View File

@ -53,7 +53,6 @@ func (p *UserProcessorImpl) CreateUser(ctx context.Context, req *contract.Create
return nil, fmt.Errorf("failed to create user: %w", err) return nil, fmt.Errorf("failed to create user: %w", err)
} }
// create default user profile
defaultFullName := userEntity.Name defaultFullName := userEntity.Name
profile := &entities.UserProfile{ profile := &entities.UserProfile{
UserID: userEntity.ID, UserID: userEntity.ID,
@ -63,6 +62,7 @@ func (p *UserProcessorImpl) CreateUser(ctx context.Context, req *contract.Create
Preferences: entities.JSONB{}, Preferences: entities.JSONB{},
NotificationPrefs: entities.JSONB{}, NotificationPrefs: entities.JSONB{},
} }
_ = p.profileRepo.Create(ctx, profile) _ = p.profileRepo.Create(ctx, profile)
return transformer.EntityToContract(userEntity), nil return transformer.EntityToContract(userEntity), nil
@ -110,8 +110,15 @@ func (p *UserProcessorImpl) GetUserByID(ctx context.Context, id uuid.UUID) (*con
if err != nil { if err != nil {
return nil, fmt.Errorf("user not found: %w", err) return nil, fmt.Errorf("user not found: %w", err)
} }
resp := transformer.EntityToContract(user)
return transformer.EntityToContract(user), nil 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
} }
func (p *UserProcessorImpl) GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error) { func (p *UserProcessorImpl) GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error) {
@ -120,20 +127,41 @@ func (p *UserProcessorImpl) GetUserByEmail(ctx context.Context, email string) (*
return nil, fmt.Errorf("user not found: %w", err) return nil, fmt.Errorf("user not found: %w", err)
} }
// Departments are now preloaded, so they're already in the response
return transformer.EntityToContract(user), nil return transformer.EntityToContract(user), nil
} }
func (p *UserProcessorImpl) ListUsers(ctx context.Context, page, limit int) ([]contract.UserResponse, int, error) { func (p *UserProcessorImpl) ListUsersWithFilters(ctx context.Context, req *contract.ListUsersRequest) ([]contract.UserResponse, int, error) {
page := req.Page
if page <= 0 {
page = 1
}
limit := req.Limit
if limit <= 0 {
limit = 10
}
offset := (page - 1) * limit offset := (page - 1) * limit
filters := map[string]interface{}{} users, totalCount, err := p.userRepo.ListWithFilters(ctx, req.Search, req.RoleCode, req.IsActive, limit, offset)
users, totalCount, err := p.userRepo.List(ctx, filters, limit, offset)
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("failed to get users: %w", err) return nil, 0, fmt.Errorf("failed to get users: %w", err)
} }
responses := transformer.EntitiesToContracts(users) responses := transformer.EntitiesToContracts(users)
userIDs := make([]uuid.UUID, 0, len(responses))
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 {
if roles, ok := rolesMap[responses[i].ID]; ok {
responses[i].Roles = transformer.RolesToContract(roles)
}
}
}
// Departments are now preloaded, so they're already in the responses
return responses, int(totalCount), nil return responses, int(totalCount), nil
} }
@ -219,12 +247,12 @@ func (p *UserProcessorImpl) GetUserPermissionCodes(ctx context.Context, userID u
return codes, nil return codes, nil
} }
func (p *UserProcessorImpl) GetUserPositions(ctx context.Context, userID uuid.UUID) ([]contract.PositionResponse, error) { func (p *UserProcessorImpl) GetUserDepartments(ctx context.Context, userID uuid.UUID) ([]contract.DepartmentResponse, error) {
positions, err := p.userRepo.GetPositionsByUserID(ctx, userID) departments, err := p.userRepo.GetDepartmentsByUserID(ctx, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return transformer.PositionsToContract(positions), nil return transformer.DepartmentsToContract(departments), nil
} }
func (p *UserProcessorImpl) GetUserProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) { func (p *UserProcessorImpl) GetUserProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) {
@ -249,3 +277,126 @@ func (p *UserProcessorImpl) UpdateUserProfile(ctx context.Context, userID uuid.U
} }
return transformer.ProfileEntityToContract(entity), nil return transformer.ProfileEntityToContract(entity), nil
} }
// GetActiveUsersForMention retrieves active users for mention purposes with optional username search
func (p *UserProcessorImpl) GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) {
if limit <= 0 {
limit = 50 // Default limit for mention suggestions
}
if limit > 100 {
limit = 100 // Max limit for mention suggestions
}
// Set isActive to true to only get active users
isActive := true
users, _, err := p.userRepo.ListWithFilters(ctx, search, nil, &isActive, limit, 0)
if err != nil {
return nil, fmt.Errorf("failed to get active users: %w", err)
}
responses := transformer.EntitiesToContracts(users)
userIDs := make([]uuid.UUID, 0, len(responses))
for i := range responses {
userIDs = append(userIDs, responses[i].ID)
}
// Load roles for the users
rolesMap, err := p.userRepo.GetRolesByUserIDs(ctx, userIDs)
if err == nil {
for i := range responses {
if roles, ok := rolesMap[responses[i].ID]; ok {
responses[i].Roles = transformer.RolesToContract(roles)
}
}
}
return responses, nil
}
// BulkCreateUsersWithTransaction creates multiple users in a transaction with proper error handling
func (p *UserProcessorImpl) BulkCreateUsersWithTransaction(ctx context.Context, userRequests []contract.BulkUserRequest) ([]contract.UserResponse, []contract.BulkUserErrorResult, error) {
created := []contract.UserResponse{}
failed := []contract.BulkUserErrorResult{}
usersToCreate := []*entities.User{}
emailMap := make(map[string]bool)
for _, req := range userRequests {
if emailMap[req.Email] {
failed = append(failed, contract.BulkUserErrorResult{
User: req,
Error: "Duplicate email in batch",
})
continue
}
emailMap[req.Email] = true
existing, _ := p.userRepo.GetByEmail(ctx, req.Email)
if existing != nil {
failed = append(failed, contract.BulkUserErrorResult{
User: req,
Error: "Email already exists",
})
continue
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
failed = append(failed, contract.BulkUserErrorResult{
User: req,
Error: "Failed to hash password",
})
continue
}
user := &entities.User{
ID: uuid.New(),
Username: req.Email,
Name: req.Name,
Email: req.Email,
PasswordHash: string(hashedPassword),
IsActive: true,
}
usersToCreate = append(usersToCreate, user)
}
if len(usersToCreate) > 0 {
// Use CreateInBatches for large datasets
err := p.userRepo.CreateInBatches(ctx, usersToCreate, 50)
if err != nil {
// If bulk creation fails, try individual creation
for i, user := range usersToCreate {
err := p.userRepo.Create(ctx, user)
if err != nil {
failed = append(failed, contract.BulkUserErrorResult{
User: userRequests[i],
Error: err.Error(),
})
} else {
// Create default profile for the user
profile := &entities.UserProfile{
UserID: user.ID,
FullName: user.Name,
}
_ = p.profileRepo.Create(ctx, profile)
created = append(created, *transformer.EntityToContract(user))
}
}
} else {
// Create profiles for all successfully created users
for _, user := range usersToCreate {
profile := &entities.UserProfile{
UserID: user.ID,
FullName: user.Name,
}
_ = p.profileRepo.Create(ctx, profile)
created = append(created, *transformer.EntityToContract(user))
}
}
}
return created, failed, nil
}

View File

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

View File

@ -3,6 +3,7 @@ package processor
import ( import (
"context" "context"
"eslogad-be/internal/entities" "eslogad-be/internal/entities"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -21,5 +22,13 @@ type UserRepository interface {
GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Role, error)
GetPermissionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Permission, error) GetPermissionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Permission, error)
GetPositionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Position, error) GetDepartmentsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Department, error)
// New optimized helpers
GetRolesByUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]entities.Role, error)
ListWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]*entities.User, int64, error)
// Bulk operations
BulkCreate(ctx context.Context, users []*entities.User) error
CreateInBatches(ctx context.Context, users []*entities.User, batchSize int) error
} }

View File

@ -0,0 +1,52 @@
package repository
import (
"context"
"eslogad-be/internal/entities"
"github.com/google/uuid"
"gorm.io/gorm"
)
type DispositionRouteRepository struct{ db *gorm.DB }
func NewDispositionRouteRepository(db *gorm.DB) *DispositionRouteRepository {
return &DispositionRouteRepository{db: db}
}
func (r *DispositionRouteRepository) Create(ctx context.Context, e *entities.DispositionRoute) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(e).Error
}
func (r *DispositionRouteRepository) Update(ctx context.Context, e *entities.DispositionRoute) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Model(&entities.DispositionRoute{}).Where("id = ?", e.ID).Updates(e).Error
}
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).
Preload("FromDepartment").
Preload("ToDepartment").
First(&e, "id = ?", id).Error; err != nil {
return nil, err
}
return &e, nil
}
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).
Preload("FromDepartment").
Preload("ToDepartment").
Order("to_department_id").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (r *DispositionRouteRepository) SetActive(ctx context.Context, id uuid.UUID, isActive bool) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Model(&entities.DispositionRoute{}).Where("id = ?", id).Update("is_active", isActive).Error
}

View File

@ -0,0 +1,307 @@
package repository
import (
"context"
"time"
"eslogad-be/internal/entities"
"github.com/google/uuid"
"gorm.io/gorm"
)
type LetterIncomingRepository struct{ db *gorm.DB }
func NewLetterIncomingRepository(db *gorm.DB) *LetterIncomingRepository {
return &LetterIncomingRepository{db: db}
}
func (r *LetterIncomingRepository) Create(ctx context.Context, e *entities.LetterIncoming) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(e).Error
}
func (r *LetterIncomingRepository) Get(ctx context.Context, id uuid.UUID) (*entities.LetterIncoming, error) {
db := DBFromContext(ctx, r.db)
var e entities.LetterIncoming
if err := db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil {
return nil, err
}
return &e, nil
}
func (r *LetterIncomingRepository) Update(ctx context.Context, e *entities.LetterIncoming) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Model(&entities.LetterIncoming{}).Where("id = ? AND deleted_at IS NULL", e.ID).Updates(e).Error
}
func (r *LetterIncomingRepository) SoftDelete(ctx context.Context, id uuid.UUID) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Exec("UPDATE letters_incoming SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL", id).Error
}
type ListIncomingLettersFilter struct {
Status *string
Query *string
}
func (r *LetterIncomingRepository) List(ctx context.Context, filter ListIncomingLettersFilter, limit, offset int) ([]entities.LetterIncoming, int64, error) {
db := DBFromContext(ctx, r.db)
query := db.WithContext(ctx).Model(&entities.LetterIncoming{}).Where("deleted_at IS NULL")
if filter.Status != nil {
query = query.Where("status = ?", *filter.Status)
}
if filter.Query != nil {
q := "%" + *filter.Query + "%"
query = query.Where("subject ILIKE ? OR reference_number ILIKE ?", q, q)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []entities.LetterIncoming
if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&list).Error; err != nil {
return nil, 0, err
}
return list, total, nil
}
type LetterIncomingAttachmentRepository struct{ db *gorm.DB }
func NewLetterIncomingAttachmentRepository(db *gorm.DB) *LetterIncomingAttachmentRepository {
return &LetterIncomingAttachmentRepository{db: db}
}
func (r *LetterIncomingAttachmentRepository) CreateBulk(ctx context.Context, list []entities.LetterIncomingAttachment) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(&list).Error
}
func (r *LetterIncomingAttachmentRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingAttachment, error) {
db := DBFromContext(ctx, r.db)
var list []entities.LetterIncomingAttachment
if err := db.WithContext(ctx).Where("letter_id = ?", letterID).Order("uploaded_at ASC").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
type LetterIncomingActivityLogRepository struct{ db *gorm.DB }
func NewLetterIncomingActivityLogRepository(db *gorm.DB) *LetterIncomingActivityLogRepository {
return &LetterIncomingActivityLogRepository{db: db}
}
func (r *LetterIncomingActivityLogRepository) Create(ctx context.Context, e *entities.LetterIncomingActivityLog) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(e).Error
}
func (r *LetterIncomingActivityLogRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingActivityLog, error) {
db := DBFromContext(ctx, r.db)
var list []entities.LetterIncomingActivityLog
if err := db.WithContext(ctx).Where("letter_id = ?", letterID).Order("occurred_at ASC").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
type LetterIncomingDispositionRepository struct{ db *gorm.DB }
func NewLetterIncomingDispositionRepository(db *gorm.DB) *LetterIncomingDispositionRepository {
return &LetterIncomingDispositionRepository{db: db}
}
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 *LetterIncomingDispositionRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingDisposition, error) {
db := DBFromContext(ctx, r.db)
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
}
type DispositionNoteRepository struct{ db *gorm.DB }
func NewDispositionNoteRepository(db *gorm.DB) *DispositionNoteRepository {
return &DispositionNoteRepository{db: db}
}
func (r *DispositionNoteRepository) Create(ctx context.Context, e *entities.DispositionNote) error {
db := DBFromContext(ctx, r.db)
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 {
return &LetterDispositionActionSelectionRepository{db: db}
}
func (r *LetterDispositionActionSelectionRepository) CreateBulk(ctx context.Context, list []entities.LetterDispositionActionSelection) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(&list).Error
}
func (r *LetterDispositionActionSelectionRepository) ListByDisposition(ctx context.Context, dispositionID uuid.UUID) ([]entities.LetterDispositionActionSelection, error) {
db := DBFromContext(ctx, r.db)
var list []entities.LetterDispositionActionSelection
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 *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 {
return &LetterDiscussionRepository{db: db}
}
func (r *LetterDiscussionRepository) Create(ctx context.Context, e *entities.LetterDiscussion) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(e).Error
}
func (r *LetterDiscussionRepository) Get(ctx context.Context, id uuid.UUID) (*entities.LetterDiscussion, error) {
db := DBFromContext(ctx, r.db)
var e entities.LetterDiscussion
if err := db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil {
return nil, err
}
return &e, nil
}
func (r *LetterDiscussionRepository) Update(ctx context.Context, e *entities.LetterDiscussion) error {
db := DBFromContext(ctx, r.db)
// ensure edited_at is set when updating
if e.EditedAt == nil {
now := time.Now()
e.EditedAt = &now
}
return db.WithContext(ctx).Model(&entities.LetterDiscussion{}).
Where("id = ?", e.ID).
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} }
func (r *AppSettingRepository) Get(ctx context.Context, key string) (*entities.AppSetting, error) {
db := DBFromContext(ctx, r.db)
var e entities.AppSetting
if err := db.WithContext(ctx).First(&e, "key = ?", key).Error; err != nil {
return nil, err
}
return &e, nil
}
func (r *AppSettingRepository) Upsert(ctx context.Context, key string, value entities.JSONB) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Exec("INSERT INTO app_settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP", key, value).Error
}
// recipients
type LetterIncomingRecipientRepository struct{ db *gorm.DB }
func NewLetterIncomingRecipientRepository(db *gorm.DB) *LetterIncomingRecipientRepository {
return &LetterIncomingRecipientRepository{db: db}
}
func (r *LetterIncomingRecipientRepository) CreateBulk(ctx context.Context, recs []entities.LetterIncomingRecipient) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(&recs).Error
}

View File

@ -0,0 +1,147 @@
package repository
import (
"context"
"eslogad-be/internal/entities"
"github.com/google/uuid"
"gorm.io/gorm"
)
type LabelRepository struct{ db *gorm.DB }
func NewLabelRepository(db *gorm.DB) *LabelRepository { return &LabelRepository{db: db} }
func (r *LabelRepository) Create(ctx context.Context, e *entities.Label) error {
return r.db.WithContext(ctx).Create(e).Error
}
func (r *LabelRepository) Update(ctx context.Context, e *entities.Label) error {
return r.db.WithContext(ctx).Model(&entities.Label{}).Where("id = ?", e.ID).Updates(e).Error
}
func (r *LabelRepository) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.Label{}, "id = ?", id).Error
}
func (r *LabelRepository) List(ctx context.Context) ([]entities.Label, error) {
var list []entities.Label
err := r.db.WithContext(ctx).Order("name ASC").Find(&list).Error
return list, err
}
func (r *LabelRepository) Get(ctx context.Context, id uuid.UUID) (*entities.Label, error) {
var e entities.Label
if err := r.db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil {
return nil, err
}
return &e, nil
}
type PriorityRepository struct{ db *gorm.DB }
func NewPriorityRepository(db *gorm.DB) *PriorityRepository { return &PriorityRepository{db: db} }
func (r *PriorityRepository) Create(ctx context.Context, e *entities.Priority) error {
return r.db.WithContext(ctx).Create(e).Error
}
func (r *PriorityRepository) Update(ctx context.Context, e *entities.Priority) error {
return r.db.WithContext(ctx).Model(&entities.Priority{}).Where("id = ?", e.ID).Updates(e).Error
}
func (r *PriorityRepository) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.Priority{}, "id = ?", id).Error
}
func (r *PriorityRepository) List(ctx context.Context) ([]entities.Priority, error) {
var list []entities.Priority
err := r.db.WithContext(ctx).Order("level ASC").Find(&list).Error
return list, err
}
func (r *PriorityRepository) Get(ctx context.Context, id uuid.UUID) (*entities.Priority, error) {
var e entities.Priority
if err := r.db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil {
return nil, err
}
return &e, nil
}
type InstitutionRepository struct{ db *gorm.DB }
func NewInstitutionRepository(db *gorm.DB) *InstitutionRepository {
return &InstitutionRepository{db: db}
}
func (r *InstitutionRepository) Create(ctx context.Context, e *entities.Institution) error {
return r.db.WithContext(ctx).Create(e).Error
}
func (r *InstitutionRepository) Update(ctx context.Context, e *entities.Institution) error {
return r.db.WithContext(ctx).Model(&entities.Institution{}).Where("id = ?", e.ID).Updates(e).Error
}
func (r *InstitutionRepository) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.Institution{}, "id = ?", id).Error
}
func (r *InstitutionRepository) List(ctx context.Context) ([]entities.Institution, error) {
var list []entities.Institution
err := r.db.WithContext(ctx).Order("name ASC").Find(&list).Error
return list, err
}
func (r *InstitutionRepository) Get(ctx context.Context, id uuid.UUID) (*entities.Institution, error) {
var e entities.Institution
if err := r.db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil {
return nil, err
}
return &e, nil
}
type DispositionActionRepository struct{ db *gorm.DB }
func NewDispositionActionRepository(db *gorm.DB) *DispositionActionRepository {
return &DispositionActionRepository{db: db}
}
func (r *DispositionActionRepository) Create(ctx context.Context, e *entities.DispositionAction) error {
return r.db.WithContext(ctx).Create(e).Error
}
func (r *DispositionActionRepository) Update(ctx context.Context, e *entities.DispositionAction) error {
return r.db.WithContext(ctx).Model(&entities.DispositionAction{}).Where("id = ?", e.ID).Updates(e).Error
}
func (r *DispositionActionRepository) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.DispositionAction{}, "id = ?", id).Error
}
func (r *DispositionActionRepository) List(ctx context.Context) ([]entities.DispositionAction, error) {
var list []entities.DispositionAction
err := r.db.WithContext(ctx).Order("sort_order NULLS LAST, label ASC").Find(&list).Error
return list, err
}
func (r *DispositionActionRepository) Get(ctx context.Context, id uuid.UUID) (*entities.DispositionAction, error) {
var e entities.DispositionAction
if err := r.db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil {
return nil, err
}
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} }
func (r *DepartmentRepository) GetByCode(ctx context.Context, code string) (*entities.Department, error) {
db := DBFromContext(ctx, r.db)
var dep entities.Department
if err := db.WithContext(ctx).Where("code = ?", code).First(&dep).Error; err != nil {
return nil, err
}
return &dep, nil
}
func (r *DepartmentRepository) Get(ctx context.Context, id uuid.UUID) (*entities.Department, error) {
db := DBFromContext(ctx, r.db)
var dep entities.Department
if err := db.WithContext(ctx).First(&dep, "id = ?", id).Error; err != nil {
return nil, err
}
return &dep, nil
}

View File

@ -0,0 +1,97 @@
package repository
import (
"context"
"eslogad-be/internal/entities"
"github.com/google/uuid"
"gorm.io/gorm"
)
type RBACRepository struct {
db *gorm.DB
}
func NewRBACRepository(db *gorm.DB) *RBACRepository { return &RBACRepository{db: db} }
// Permissions
func (r *RBACRepository) CreatePermission(ctx context.Context, p *entities.Permission) error {
return r.db.WithContext(ctx).Create(p).Error
}
func (r *RBACRepository) UpdatePermission(ctx context.Context, p *entities.Permission) error {
return r.db.WithContext(ctx).Model(&entities.Permission{}).Where("id = ?", p.ID).Updates(p).Error
}
func (r *RBACRepository) DeletePermission(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.Permission{}, "id = ?", id).Error
}
func (r *RBACRepository) ListPermissions(ctx context.Context) ([]entities.Permission, error) {
var perms []entities.Permission
if err := r.db.WithContext(ctx).Order("code ASC").Find(&perms).Error; err != nil {
return nil, err
}
return perms, nil
}
func (r *RBACRepository) GetPermissionByCode(ctx context.Context, code string) (*entities.Permission, error) {
var p entities.Permission
if err := r.db.WithContext(ctx).First(&p, "code = ?", code).Error; err != nil {
return nil, err
}
return &p, nil
}
// Roles
func (r *RBACRepository) CreateRole(ctx context.Context, role *entities.Role) error {
return r.db.WithContext(ctx).Create(role).Error
}
func (r *RBACRepository) UpdateRole(ctx context.Context, role *entities.Role) error {
return r.db.WithContext(ctx).Model(&entities.Role{}).Where("id = ?", role.ID).Updates(role).Error
}
func (r *RBACRepository) DeleteRole(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.Role{}, "id = ?", id).Error
}
func (r *RBACRepository) ListRoles(ctx context.Context) ([]entities.Role, error) {
var roles []entities.Role
if err := r.db.WithContext(ctx).Order("name ASC").Find(&roles).Error; err != nil {
return nil, err
}
return roles, nil
}
func (r *RBACRepository) GetRoleByCode(ctx context.Context, code string) (*entities.Role, error) {
var role entities.Role
if err := r.db.WithContext(ctx).First(&role, "code = ?", code).Error; err != nil {
return nil, err
}
return &role, nil
}
func (r *RBACRepository) SetRolePermissionsByCodes(ctx context.Context, roleID uuid.UUID, permCodes []string) error {
if err := r.db.WithContext(ctx).Where("role_id = ?", roleID).Delete(&entities.RolePermission{}).Error; err != nil {
return err
}
if len(permCodes) == 0 {
return nil
}
var perms []entities.Permission
if err := r.db.WithContext(ctx).Where("code IN ?", permCodes).Find(&perms).Error; err != nil {
return err
}
pairs := make([]entities.RolePermission, 0, len(perms))
for _, p := range perms {
pairs = append(pairs, entities.RolePermission{RoleID: roleID, PermissionID: p.ID})
}
return r.db.WithContext(ctx).Create(&pairs).Error
}
func (r *RBACRepository) GetPermissionsByRoleID(ctx context.Context, roleID uuid.UUID) ([]entities.Permission, error) {
var perms []entities.Permission
if err := r.db.WithContext(ctx).
Table("permissions p").
Select("p.*").
Joins("JOIN role_permissions rp ON rp.permission_id = p.id").
Where("rp.role_id = ?", roleID).
Find(&perms).Error; err != nil {
return nil, err
}
return perms, nil
}

View File

@ -0,0 +1,35 @@
package repository
import (
"context"
"gorm.io/gorm"
)
type txKeyType struct{}
var txKey = txKeyType{}
// DBFromContext returns the transactional *gorm.DB from context if present; otherwise returns base.
func DBFromContext(ctx context.Context, base *gorm.DB) *gorm.DB {
if v := ctx.Value(txKey); v != nil {
if tx, ok := v.(*gorm.DB); ok && tx != nil {
return tx
}
}
return base
}
type TxManager struct {
db *gorm.DB
}
func NewTxManager(db *gorm.DB) *TxManager { return &TxManager{db: db} }
// WithTransaction runs fn inside a DB transaction, injecting the *gorm.DB tx into ctx.
func (m *TxManager) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
ctxTx := context.WithValue(ctx, txKey, tx)
return fn(ctxTx)
})
}

View File

@ -0,0 +1,34 @@
package repository
import (
"context"
"github.com/google/uuid"
"gorm.io/gorm"
)
type UserDepartmentRepository struct{ db *gorm.DB }
func NewUserDepartmentRepository(db *gorm.DB) *UserDepartmentRepository {
return &UserDepartmentRepository{db: db}
}
type userDepartmentRow struct {
UserID uuid.UUID `gorm:"column:user_id"`
DepartmentID uuid.UUID `gorm:"column:department_id"`
}
// ListActiveByDepartmentIDs returns active user-department memberships for given department IDs.
func (r *UserDepartmentRepository) ListActiveByDepartmentIDs(ctx context.Context, departmentIDs []uuid.UUID) ([]userDepartmentRow, error) {
db := DBFromContext(ctx, r.db)
rows := make([]userDepartmentRow, 0)
if len(departmentIDs) == 0 {
return rows, nil
}
err := db.WithContext(ctx).
Table("user_department").
Select("user_id, department_id").
Where("department_id IN ? AND removed_at IS NULL", departmentIDs).
Find(&rows).Error
return rows, err
}

View File

@ -10,22 +10,25 @@ import (
) )
type UserRepositoryImpl struct { type UserRepositoryImpl struct {
db *gorm.DB b *gorm.DB
} }
func NewUserRepository(db *gorm.DB) *UserRepositoryImpl { func NewUserRepository(db *gorm.DB) *UserRepositoryImpl {
return &UserRepositoryImpl{ return &UserRepositoryImpl{
db: db, b: db,
} }
} }
func (r *UserRepositoryImpl) Create(ctx context.Context, user *entities.User) error { func (r *UserRepositoryImpl) Create(ctx context.Context, user *entities.User) error {
return r.db.WithContext(ctx).Create(user).Error return r.b.WithContext(ctx).Create(user).Error
} }
func (r *UserRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.User, error) { func (r *UserRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.User, error) {
var user entities.User var user entities.User
err := r.db.WithContext(ctx).First(&user, "id = ?", id).Error err := r.b.WithContext(ctx).
Preload("Profile").
Preload("Departments").
First(&user, "id = ?", id).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -34,7 +37,10 @@ func (r *UserRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entiti
func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*entities.User, error) { func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*entities.User, error) {
var user entities.User var user entities.User
err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error err := r.b.WithContext(ctx).
Preload("Profile").
Preload("Departments").
Where("email = ?", email).First(&user).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -43,34 +49,36 @@ func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*ent
func (r *UserRepositoryImpl) GetByRole(ctx context.Context, role entities.UserRole) ([]*entities.User, error) { func (r *UserRepositoryImpl) GetByRole(ctx context.Context, role entities.UserRole) ([]*entities.User, error) {
var users []*entities.User var users []*entities.User
err := r.db.WithContext(ctx).Where("role = ?", role).Find(&users).Error err := r.b.WithContext(ctx).Preload("Profile").Preload("Departments").Where("role = ?", role).Find(&users).Error
return users, err return users, err
} }
func (r *UserRepositoryImpl) GetActiveUsers(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error) { func (r *UserRepositoryImpl) GetActiveUsers(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error) {
var users []*entities.User var users []*entities.User
err := r.db.WithContext(ctx). err := r.b.WithContext(ctx).
Where(" is_active = ?", organizationID, true). Where(" is_active = ?", organizationID, true).
Preload("Profile").
Preload("Departments").
Find(&users).Error Find(&users).Error
return users, err return users, err
} }
func (r *UserRepositoryImpl) Update(ctx context.Context, user *entities.User) error { func (r *UserRepositoryImpl) Update(ctx context.Context, user *entities.User) error {
return r.db.WithContext(ctx).Save(user).Error return r.b.WithContext(ctx).Save(user).Error
} }
func (r *UserRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error { func (r *UserRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.User{}, "id = ?", id).Error return r.b.WithContext(ctx).Delete(&entities.User{}, "id = ?", id).Error
} }
func (r *UserRepositoryImpl) UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error { func (r *UserRepositoryImpl) UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error {
return r.db.WithContext(ctx).Model(&entities.User{}). return r.b.WithContext(ctx).Model(&entities.User{}).
Where("id = ?", id). Where("id = ?", id).
Update("password_hash", passwordHash).Error Update("password_hash", passwordHash).Error
} }
func (r *UserRepositoryImpl) UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error { func (r *UserRepositoryImpl) UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error {
return r.db.WithContext(ctx).Model(&entities.User{}). return r.b.WithContext(ctx).Model(&entities.User{}).
Where("id = ?", id). Where("id = ?", id).
Update("is_active", isActive).Error Update("is_active", isActive).Error
} }
@ -79,7 +87,7 @@ func (r *UserRepositoryImpl) List(ctx context.Context, filters map[string]interf
var users []*entities.User var users []*entities.User
var total int64 var total int64
query := r.db.WithContext(ctx).Model(&entities.User{}) query := r.b.WithContext(ctx).Model(&entities.User{})
for key, value := range filters { for key, value := range filters {
query = query.Where(key+" = ?", value) query = query.Where(key+" = ?", value)
@ -89,13 +97,13 @@ func (r *UserRepositoryImpl) List(ctx context.Context, filters map[string]interf
return nil, 0, err return nil, 0, err
} }
err := query.Limit(limit).Offset(offset).Find(&users).Error err := query.Limit(limit).Offset(offset).Preload("Profile").Preload("Departments").Find(&users).Error
return users, total, err return users, total, err
} }
func (r *UserRepositoryImpl) Count(ctx context.Context, filters map[string]interface{}) (int64, error) { func (r *UserRepositoryImpl) Count(ctx context.Context, filters map[string]interface{}) (int64, error) {
var count int64 var count int64
query := r.db.WithContext(ctx).Model(&entities.User{}) query := r.b.WithContext(ctx).Model(&entities.User{})
for key, value := range filters { for key, value := range filters {
query = query.Where(key+" = ?", value) query = query.Where(key+" = ?", value)
@ -108,7 +116,7 @@ func (r *UserRepositoryImpl) Count(ctx context.Context, filters map[string]inter
// RBAC helpers // RBAC helpers
func (r *UserRepositoryImpl) GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) { func (r *UserRepositoryImpl) GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) {
var roles []entities.Role var roles []entities.Role
err := r.db.WithContext(ctx). err := r.b.WithContext(ctx).
Table("roles as r"). Table("roles as r").
Select("r.*"). Select("r.*").
Joins("JOIN user_role ur ON ur.role_id = r.id AND ur.removed_at IS NULL"). Joins("JOIN user_role ur ON ur.role_id = r.id AND ur.removed_at IS NULL").
@ -119,7 +127,7 @@ func (r *UserRepositoryImpl) GetRolesByUserID(ctx context.Context, userID uuid.U
func (r *UserRepositoryImpl) GetPermissionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Permission, error) { func (r *UserRepositoryImpl) GetPermissionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Permission, error) {
var perms []entities.Permission var perms []entities.Permission
err := r.db.WithContext(ctx). err := r.b.WithContext(ctx).
Table("permissions as p"). Table("permissions as p").
Select("DISTINCT p.*"). Select("DISTINCT p.*").
Joins("JOIN role_permissions rp ON rp.permission_id = p.id"). Joins("JOIN role_permissions rp ON rp.permission_id = p.id").
@ -129,13 +137,99 @@ func (r *UserRepositoryImpl) GetPermissionsByUserID(ctx context.Context, userID
return perms, err return perms, err
} }
func (r *UserRepositoryImpl) GetPositionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Position, error) { func (r *UserRepositoryImpl) GetDepartmentsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Department, error) {
var positions []entities.Position var departments []entities.Department
err := r.db.WithContext(ctx). err := r.b.WithContext(ctx).
Table("positions as p"). Table("departments as d").
Select("p.*"). Select("d.*").
Joins("JOIN user_position up ON up.position_id = p.id AND up.removed_at IS NULL"). Joins("JOIN user_department ud ON ud.department_id = d.id AND ud.removed_at IS NULL").
Where("up.user_id = ?", userID). Where("ud.user_id = ?", userID).
Find(&positions).Error Find(&departments).Error
return positions, err return departments, err
}
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
}
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").
Select("ur.user_id, r.id as role_id, r.name, r.code").
Joins("JOIN roles r ON r.id = ur.role_id").
Where("ur.removed_at IS NULL AND ur.user_id IN ?", userIDs).
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, rw := range rows {
role := entities.Role{ID: rw.RoleID, Name: rw.Name, Code: rw.Code}
result[rw.UserID] = append(result[rw.UserID], role)
}
return result, nil
}
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
q := r.b.WithContext(ctx).Model(&entities.User{})
if search != nil && *search != "" {
like := "%" + *search + "%"
q = q.Where("name ILIKE ? OR email ILIKE ?", like, like)
}
if isActive != nil {
q = q.Where("is_active = ?", *isActive)
}
if roleCode != nil && *roleCode != "" {
q = q.Joins("JOIN user_role ur ON ur.user_id = users.id AND ur.removed_at IS NULL").
Joins("JOIN roles r ON r.id = ur.role_id").
Where("r.code = ?", *roleCode)
}
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := q.Limit(limit).Offset(offset).Preload("Profile").Preload("Departments").Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
// BulkCreate creates multiple users in a single database transaction
func (r *UserRepositoryImpl) BulkCreate(ctx context.Context, users []*entities.User) error {
if len(users) == 0 {
return nil
}
return r.b.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Create all users in a single batch
if err := tx.Create(&users).Error; err != nil {
return err
}
return nil
})
}
// CreateInBatches creates users in smaller batches to avoid memory issues
func (r *UserRepositoryImpl) CreateInBatches(ctx context.Context, users []*entities.User, batchSize int) error {
if len(users) == 0 {
return nil
}
if batchSize <= 0 {
batchSize = 100 // Default batch size
}
return r.b.WithContext(ctx).CreateInBatches(users, batchSize).Error
} }

View File

@ -0,0 +1,140 @@
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
}
func (r *VoteEventRepositoryImpl) GetVotedCount(ctx context.Context, eventID uuid.UUID) (int64, error) {
var count int64
err := r.db.WithContext(ctx).
Model(&entities.Vote{}).
Where("vote_event_id = ?", eventID).
Count(&count).Error
return count, err
}
func (r *VoteEventRepositoryImpl) GetTotalActiveUsersCount(ctx context.Context) (int64, error) {
var count int64
err := r.db.WithContext(ctx).
Model(&entities.User{}).
Where("is_active = ?", true).
Count(&count).Error
return count, err
}

View File

@ -12,9 +12,86 @@ type UserHandler interface {
UpdateProfile(c *gin.Context) UpdateProfile(c *gin.Context)
ChangePassword(c *gin.Context) ChangePassword(c *gin.Context)
ListTitles(c *gin.Context) ListTitles(c *gin.Context)
GetActiveUsersForMention(c *gin.Context)
BulkCreateUsers(c *gin.Context)
BulkCreateUsersAsync(c *gin.Context)
GetBulkJobStatus(c *gin.Context)
} }
type FileHandler interface { type FileHandler interface {
UploadProfileAvatar(c *gin.Context) UploadProfileAvatar(c *gin.Context)
UploadDocument(c *gin.Context) UploadDocument(c *gin.Context)
} }
type RBACHandler interface {
CreatePermission(c *gin.Context)
UpdatePermission(c *gin.Context)
DeletePermission(c *gin.Context)
ListPermissions(c *gin.Context)
CreateRole(c *gin.Context)
UpdateRole(c *gin.Context)
DeleteRole(c *gin.Context)
ListRoles(c *gin.Context)
}
type MasterHandler interface {
// labels
CreateLabel(c *gin.Context)
UpdateLabel(c *gin.Context)
DeleteLabel(c *gin.Context)
ListLabels(c *gin.Context)
// priorities
CreatePriority(c *gin.Context)
UpdatePriority(c *gin.Context)
DeletePriority(c *gin.Context)
ListPriorities(c *gin.Context)
// institutions
CreateInstitution(c *gin.Context)
UpdateInstitution(c *gin.Context)
DeleteInstitution(c *gin.Context)
ListInstitutions(c *gin.Context)
// disposition actions
CreateDispositionAction(c *gin.Context)
UpdateDispositionAction(c *gin.Context)
DeleteDispositionAction(c *gin.Context)
ListDispositionActions(c *gin.Context)
}
type LetterHandler interface {
CreateIncomingLetter(c *gin.Context)
GetIncomingLetter(c *gin.Context)
ListIncomingLetters(c *gin.Context)
UpdateIncomingLetter(c *gin.Context)
DeleteIncomingLetter(c *gin.Context)
CreateDispositions(c *gin.Context)
//ListDispositionsByLetter(c *gin.Context)
GetEnhancedDispositionsByLetter(c *gin.Context)
CreateDiscussion(c *gin.Context)
UpdateDiscussion(c *gin.Context)
}
type DispositionRouteHandler interface {
Create(c *gin.Context)
Update(c *gin.Context)
Get(c *gin.Context)
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)
GetVoteEventDetails(c *gin.Context)
}

View File

@ -14,6 +14,11 @@ type Router struct {
authMiddleware AuthMiddleware authMiddleware AuthMiddleware
userHandler UserHandler userHandler UserHandler
fileHandler FileHandler fileHandler FileHandler
rbacHandler RBACHandler
masterHandler MasterHandler
letterHandler LetterHandler
dispRouteHandler DispositionRouteHandler
voteEventHandler VoteEventHandler
} }
func NewRouter( func NewRouter(
@ -23,6 +28,11 @@ func NewRouter(
healthHandler HealthHandler, healthHandler HealthHandler,
userHandler UserHandler, userHandler UserHandler,
fileHandler FileHandler, fileHandler FileHandler,
rbacHandler RBACHandler,
masterHandler MasterHandler,
letterHandler LetterHandler,
dispRouteHandler DispositionRouteHandler,
voteEventHandler VoteEventHandler,
) *Router { ) *Router {
return &Router{ return &Router{
config: cfg, config: cfg,
@ -31,6 +41,11 @@ func NewRouter(
healthHandler: healthHandler, healthHandler: healthHandler,
userHandler: userHandler, userHandler: userHandler,
fileHandler: fileHandler, fileHandler: fileHandler,
rbacHandler: rbacHandler,
masterHandler: masterHandler,
letterHandler: letterHandler,
dispRouteHandler: dispRouteHandler,
voteEventHandler: voteEventHandler,
} }
} }
@ -38,6 +53,7 @@ func (r *Router) Init() *gin.Engine {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
engine := gin.New() engine := gin.New()
engine.Use( engine.Use(
middleware.CORS(),
middleware.JsonAPI(), middleware.JsonAPI(),
middleware.CorrelationID(), middleware.CorrelationID(),
middleware.Recover(), middleware.Recover(),
@ -64,11 +80,15 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
users := v1.Group("/users") users := v1.Group("/users")
users.Use(r.authMiddleware.RequireAuth()) users.Use(r.authMiddleware.RequireAuth())
{ {
users.GET("", r.authMiddleware.RequirePermissions("user.view"), r.userHandler.ListUsers) users.GET("", r.userHandler.ListUsers)
users.POST("/bulk", r.userHandler.BulkCreateUsersAsync)
users.POST("/bulk/async", r.userHandler.BulkCreateUsersAsync)
users.GET("/bulk/job/:jobId", r.userHandler.GetBulkJobStatus)
users.GET("/profile", r.userHandler.GetProfile) users.GET("/profile", r.userHandler.GetProfile)
users.PUT("/profile", r.userHandler.UpdateProfile) users.PUT("/profile", r.userHandler.UpdateProfile)
users.PUT(":id/password", r.userHandler.ChangePassword) users.PUT(":id/password", r.userHandler.ChangePassword)
users.GET("/titles", r.userHandler.ListTitles) users.GET("/titles", r.userHandler.ListTitles)
users.GET("/mention", r.userHandler.GetActiveUsersForMention)
users.POST("/profile/avatar", r.fileHandler.UploadProfileAvatar) users.POST("/profile/avatar", r.fileHandler.UploadProfileAvatar)
} }
@ -77,5 +97,96 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
{ {
files.POST("/documents", r.fileHandler.UploadDocument) files.POST("/documents", r.fileHandler.UploadDocument)
} }
rbac := v1.Group("/rbac")
rbac.Use(r.authMiddleware.RequireAuth())
{
rbac.GET("/permissions", r.rbacHandler.ListPermissions)
rbac.POST("/permissions", r.rbacHandler.CreatePermission)
rbac.PUT("/permissions/:id", r.rbacHandler.UpdatePermission)
rbac.DELETE("/permissions/:id", r.rbacHandler.DeletePermission)
rbac.GET("/roles", r.rbacHandler.ListRoles)
rbac.POST("/roles", r.rbacHandler.CreateRole)
rbac.PUT("/roles/:id", r.rbacHandler.UpdateRole)
rbac.DELETE("/roles/:id", r.rbacHandler.DeleteRole)
}
master := v1.Group("/master")
master.Use(r.authMiddleware.RequireAuth())
{
master.GET("/labels", r.masterHandler.ListLabels)
master.POST("/labels", r.masterHandler.CreateLabel)
master.PUT("/labels/:id", r.masterHandler.UpdateLabel)
master.DELETE("/labels/:id", r.masterHandler.DeleteLabel)
master.GET("/priorities", r.masterHandler.ListPriorities)
master.POST("/priorities", r.masterHandler.CreatePriority)
master.PUT("/priorities/:id", r.masterHandler.UpdatePriority)
master.DELETE("/priorities/:id", r.masterHandler.DeletePriority)
master.GET("/institutions", r.masterHandler.ListInstitutions)
master.POST("/institutions", r.masterHandler.CreateInstitution)
master.PUT("/institutions/:id", r.masterHandler.UpdateInstitution)
master.DELETE("/institutions/:id", r.masterHandler.DeleteInstitution)
master.GET("/disposition-actions", r.masterHandler.ListDispositionActions)
master.POST("/disposition-actions", r.masterHandler.CreateDispositionAction)
master.PUT("/disposition-actions/:id", r.masterHandler.UpdateDispositionAction)
master.DELETE("/disposition-actions/:id", r.masterHandler.DeleteDispositionAction)
}
lettersch := v1.Group("/letters")
lettersch.Use(r.authMiddleware.RequireAuth())
{
lettersch.POST("/incoming", r.letterHandler.CreateIncomingLetter)
lettersch.GET("/incoming/:id", r.letterHandler.GetIncomingLetter)
lettersch.GET("/incoming", r.letterHandler.ListIncomingLetters)
lettersch.PUT("/incoming/:id", r.letterHandler.UpdateIncomingLetter)
lettersch.DELETE("/incoming/:id", r.letterHandler.DeleteIncomingLetter)
lettersch.POST("/dispositions/:letter_id", r.letterHandler.CreateDispositions)
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)
}
droutes := v1.Group("/disposition-routes")
droutes.Use(r.authMiddleware.RequireAuth())
{
droutes.POST("", r.dispRouteHandler.Create)
droutes.GET(":id", r.dispRouteHandler.Get)
droutes.PUT(":id", r.dispRouteHandler.Update)
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)
voteEvents.GET("/:id/details", r.voteEventHandler.GetVoteEventDetails)
}
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)
}
} }
} }

View File

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

View File

@ -0,0 +1,70 @@
package service
import (
"context"
"eslogad-be/internal/contract"
"eslogad-be/internal/entities"
"eslogad-be/internal/repository"
"eslogad-be/internal/transformer"
"github.com/google/uuid"
)
type DispositionRouteServiceImpl struct {
repo *repository.DispositionRouteRepository
}
func NewDispositionRouteService(repo *repository.DispositionRouteRepository) *DispositionRouteServiceImpl {
return &DispositionRouteServiceImpl{repo: repo}
}
func (s *DispositionRouteServiceImpl) Create(ctx context.Context, req *contract.CreateDispositionRouteRequest) (*contract.DispositionRouteResponse, error) {
entity := &entities.DispositionRoute{FromDepartmentID: req.FromDepartmentID, ToDepartmentID: req.ToDepartmentID}
if req.IsActive != nil {
entity.IsActive = *req.IsActive
}
if req.AllowedActions != nil {
entity.AllowedActions = entities.JSONB(*req.AllowedActions)
}
if err := s.repo.Create(ctx, entity); err != nil {
return nil, err
}
resp := transformer.DispositionRoutesToContract([]entities.DispositionRoute{*entity})[0]
return &resp, nil
}
func (s *DispositionRouteServiceImpl) Update(ctx context.Context, id uuid.UUID, req *contract.UpdateDispositionRouteRequest) (*contract.DispositionRouteResponse, error) {
entity, err := s.repo.Get(ctx, id)
if err != nil {
return nil, err
}
if req.IsActive != nil {
entity.IsActive = *req.IsActive
}
if req.AllowedActions != nil {
entity.AllowedActions = entities.JSONB(*req.AllowedActions)
}
if err := s.repo.Update(ctx, entity); err != nil {
return nil, err
}
resp := transformer.DispositionRoutesToContract([]entities.DispositionRoute{*entity})[0]
return &resp, nil
}
func (s *DispositionRouteServiceImpl) Get(ctx context.Context, id uuid.UUID) (*contract.DispositionRouteResponse, error) {
entity, err := s.repo.Get(ctx, id)
if err != nil {
return nil, err
}
resp := transformer.DispositionRoutesToContract([]entities.DispositionRoute{*entity})[0]
return &resp, nil
}
func (s *DispositionRouteServiceImpl) ListByFromDept(ctx context.Context, from uuid.UUID) (*contract.ListDispositionRoutesResponse, error) {
list, err := s.repo.ListByFromDept(ctx, from)
if err != nil {
return nil, err
}
return &contract.ListDispositionRoutesResponse{Routes: transformer.DispositionRoutesToContract(list)}, nil
}
func (s *DispositionRouteServiceImpl) SetActive(ctx context.Context, id uuid.UUID, active bool) error {
return s.repo.SetActive(ctx, id, active)
}

View File

@ -0,0 +1,63 @@
package service
import (
"context"
"eslogad-be/internal/contract"
"github.com/google/uuid"
)
type LetterProcessor interface {
CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error)
GetIncomingLetterByID(ctx context.Context, id uuid.UUID) (*contract.IncomingLetterResponse, error)
ListIncomingLetters(ctx context.Context, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error)
UpdateIncomingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateIncomingLetterRequest) (*contract.IncomingLetterResponse, error)
SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error
CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*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)
}
type LetterServiceImpl struct {
processor LetterProcessor
}
func NewLetterService(processor LetterProcessor) *LetterServiceImpl {
return &LetterServiceImpl{processor: processor}
}
func (s *LetterServiceImpl) CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) {
return s.processor.CreateIncomingLetter(ctx, req)
}
func (s *LetterServiceImpl) GetIncomingLetterByID(ctx context.Context, id uuid.UUID) (*contract.IncomingLetterResponse, error) {
return s.processor.GetIncomingLetterByID(ctx, id)
}
func (s *LetterServiceImpl) ListIncomingLetters(ctx context.Context, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error) {
return s.processor.ListIncomingLetters(ctx, req)
}
func (s *LetterServiceImpl) UpdateIncomingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) {
return s.processor.UpdateIncomingLetter(ctx, id, req)
}
func (s *LetterServiceImpl) SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error {
return s.processor.SoftDeleteIncomingLetter(ctx, id)
}
func (s *LetterServiceImpl) CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) {
return s.processor.CreateDispositions(ctx, req)
}
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) {
return s.processor.CreateDiscussion(ctx, letterID, req)
}
func (s *LetterServiceImpl) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) {
return s.processor.UpdateDiscussion(ctx, letterID, discussionID, req)
}

View File

@ -0,0 +1,214 @@
package service
import (
"context"
"eslogad-be/internal/contract"
"eslogad-be/internal/entities"
"eslogad-be/internal/repository"
"eslogad-be/internal/transformer"
"github.com/google/uuid"
)
type MasterServiceImpl struct {
labelRepo *repository.LabelRepository
priorityRepo *repository.PriorityRepository
institutionRepo *repository.InstitutionRepository
dispRepo *repository.DispositionActionRepository
}
func NewMasterService(label *repository.LabelRepository, priority *repository.PriorityRepository, institution *repository.InstitutionRepository, disp *repository.DispositionActionRepository) *MasterServiceImpl {
return &MasterServiceImpl{labelRepo: label, priorityRepo: priority, institutionRepo: institution, dispRepo: disp}
}
// Labels
func (s *MasterServiceImpl) CreateLabel(ctx context.Context, req *contract.CreateLabelRequest) (*contract.LabelResponse, error) {
entity := &entities.Label{Name: req.Name, Color: req.Color}
if err := s.labelRepo.Create(ctx, entity); err != nil {
return nil, err
}
resp := transformer.LabelsToContract([]entities.Label{*entity})[0]
return &resp, nil
}
func (s *MasterServiceImpl) UpdateLabel(ctx context.Context, id uuid.UUID, req *contract.UpdateLabelRequest) (*contract.LabelResponse, error) {
entity := &entities.Label{ID: id}
if req.Name != nil {
entity.Name = *req.Name
}
if req.Color != nil {
entity.Color = req.Color
}
if err := s.labelRepo.Update(ctx, entity); err != nil {
return nil, err
}
e, err := s.labelRepo.Get(ctx, id)
if err != nil {
return nil, err
}
resp := transformer.LabelsToContract([]entities.Label{*e})[0]
return &resp, nil
}
func (s *MasterServiceImpl) DeleteLabel(ctx context.Context, id uuid.UUID) error {
return s.labelRepo.Delete(ctx, id)
}
func (s *MasterServiceImpl) ListLabels(ctx context.Context) (*contract.ListLabelsResponse, error) {
list, err := s.labelRepo.List(ctx)
if err != nil {
return nil, err
}
return &contract.ListLabelsResponse{Labels: transformer.LabelsToContract(list)}, nil
}
// Priorities
func (s *MasterServiceImpl) CreatePriority(ctx context.Context, req *contract.CreatePriorityRequest) (*contract.PriorityResponse, error) {
entity := &entities.Priority{Name: req.Name, Level: req.Level}
if err := s.priorityRepo.Create(ctx, entity); err != nil {
return nil, err
}
resp := transformer.PrioritiesToContract([]entities.Priority{*entity})[0]
return &resp, nil
}
func (s *MasterServiceImpl) UpdatePriority(ctx context.Context, id uuid.UUID, req *contract.UpdatePriorityRequest) (*contract.PriorityResponse, error) {
entity := &entities.Priority{ID: id}
if req.Name != nil {
entity.Name = *req.Name
}
if req.Level != nil {
entity.Level = *req.Level
}
if err := s.priorityRepo.Update(ctx, entity); err != nil {
return nil, err
}
e, err := s.priorityRepo.Get(ctx, id)
if err != nil {
return nil, err
}
resp := transformer.PrioritiesToContract([]entities.Priority{*e})[0]
return &resp, nil
}
func (s *MasterServiceImpl) DeletePriority(ctx context.Context, id uuid.UUID) error {
return s.priorityRepo.Delete(ctx, id)
}
func (s *MasterServiceImpl) ListPriorities(ctx context.Context) (*contract.ListPrioritiesResponse, error) {
list, err := s.priorityRepo.List(ctx)
if err != nil {
return nil, err
}
return &contract.ListPrioritiesResponse{Priorities: transformer.PrioritiesToContract(list)}, nil
}
// Institutions
func (s *MasterServiceImpl) CreateInstitution(ctx context.Context, req *contract.CreateInstitutionRequest) (*contract.InstitutionResponse, error) {
entity := &entities.Institution{Name: req.Name, Type: entities.InstitutionType(req.Type), Address: req.Address, ContactPerson: req.ContactPerson, Phone: req.Phone, Email: req.Email}
if err := s.institutionRepo.Create(ctx, entity); err != nil {
return nil, err
}
resp := transformer.InstitutionsToContract([]entities.Institution{*entity})[0]
return &resp, nil
}
func (s *MasterServiceImpl) UpdateInstitution(ctx context.Context, id uuid.UUID, req *contract.UpdateInstitutionRequest) (*contract.InstitutionResponse, error) {
entity := &entities.Institution{ID: id}
if req.Name != nil {
entity.Name = *req.Name
}
if req.Type != nil {
entity.Type = entities.InstitutionType(*req.Type)
}
if req.Address != nil {
entity.Address = req.Address
}
if req.ContactPerson != nil {
entity.ContactPerson = req.ContactPerson
}
if req.Phone != nil {
entity.Phone = req.Phone
}
if req.Email != nil {
entity.Email = req.Email
}
if err := s.institutionRepo.Update(ctx, entity); err != nil {
return nil, err
}
e, err := s.institutionRepo.Get(ctx, id)
if err != nil {
return nil, err
}
resp := transformer.InstitutionsToContract([]entities.Institution{*e})[0]
return &resp, nil
}
func (s *MasterServiceImpl) DeleteInstitution(ctx context.Context, id uuid.UUID) error {
return s.institutionRepo.Delete(ctx, id)
}
func (s *MasterServiceImpl) ListInstitutions(ctx context.Context) (*contract.ListInstitutionsResponse, error) {
list, err := s.institutionRepo.List(ctx)
if err != nil {
return nil, err
}
return &contract.ListInstitutionsResponse{Institutions: transformer.InstitutionsToContract(list)}, nil
}
// Disposition Actions
func (s *MasterServiceImpl) CreateDispositionAction(ctx context.Context, req *contract.CreateDispositionActionRequest) (*contract.DispositionActionResponse, error) {
entity := &entities.DispositionAction{Code: req.Code, Label: req.Label, Description: req.Description}
if req.RequiresNote != nil {
entity.RequiresNote = *req.RequiresNote
}
if req.GroupName != nil {
entity.GroupName = req.GroupName
}
if req.SortOrder != nil {
entity.SortOrder = req.SortOrder
}
if req.IsActive != nil {
entity.IsActive = *req.IsActive
}
if err := s.dispRepo.Create(ctx, entity); err != nil {
return nil, err
}
resp := transformer.DispositionActionsToContract([]entities.DispositionAction{*entity})[0]
return &resp, nil
}
func (s *MasterServiceImpl) UpdateDispositionAction(ctx context.Context, id uuid.UUID, req *contract.UpdateDispositionActionRequest) (*contract.DispositionActionResponse, error) {
entity := &entities.DispositionAction{ID: id}
if req.Code != nil {
entity.Code = *req.Code
}
if req.Label != nil {
entity.Label = *req.Label
}
if req.Description != nil {
entity.Description = req.Description
}
if req.RequiresNote != nil {
entity.RequiresNote = *req.RequiresNote
}
if req.GroupName != nil {
entity.GroupName = req.GroupName
}
if req.SortOrder != nil {
entity.SortOrder = req.SortOrder
}
if req.IsActive != nil {
entity.IsActive = *req.IsActive
}
if err := s.dispRepo.Update(ctx, entity); err != nil {
return nil, err
}
e, err := s.dispRepo.Get(ctx, id)
if err != nil {
return nil, err
}
resp := transformer.DispositionActionsToContract([]entities.DispositionAction{*e})[0]
return &resp, nil
}
func (s *MasterServiceImpl) DeleteDispositionAction(ctx context.Context, id uuid.UUID) error {
return s.dispRepo.Delete(ctx, id)
}
func (s *MasterServiceImpl) ListDispositionActions(ctx context.Context) (*contract.ListDispositionActionsResponse, error) {
list, err := s.dispRepo.List(ctx)
if err != nil {
return nil, err
}
return &contract.ListDispositionActionsResponse{Actions: transformer.DispositionActionsToContract(list)}, nil
}

View File

@ -0,0 +1,128 @@
package service
import (
"context"
"eslogad-be/internal/contract"
"eslogad-be/internal/entities"
"eslogad-be/internal/repository"
"eslogad-be/internal/transformer"
"github.com/google/uuid"
)
type RBACServiceImpl struct {
repo *repository.RBACRepository
}
func NewRBACService(repo *repository.RBACRepository) *RBACServiceImpl {
return &RBACServiceImpl{repo: repo}
}
// Permissions
func (s *RBACServiceImpl) CreatePermission(ctx context.Context, req *contract.CreatePermissionRequest) (*contract.PermissionResponse, error) {
p := &entities.Permission{Code: req.Code}
if req.Description != nil {
p.Description = *req.Description
}
if err := s.repo.CreatePermission(ctx, p); err != nil {
return nil, err
}
return &contract.PermissionResponse{ID: p.ID, Code: p.Code, Description: &p.Description, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt}, nil
}
func (s *RBACServiceImpl) UpdatePermission(ctx context.Context, id uuid.UUID, req *contract.UpdatePermissionRequest) (*contract.PermissionResponse, error) {
p := &entities.Permission{ID: id}
if req.Code != nil {
p.Code = *req.Code
}
if req.Description != nil {
p.Description = *req.Description
}
if err := s.repo.UpdatePermission(ctx, p); err != nil {
return nil, err
}
// fetch full row
perms, err := s.repo.ListPermissions(ctx)
if err != nil {
return nil, err
}
for _, x := range perms {
if x.ID == id {
return &contract.PermissionResponse{ID: x.ID, Code: x.Code, Description: &x.Description, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt}, nil
}
}
return nil, nil
}
func (s *RBACServiceImpl) DeletePermission(ctx context.Context, id uuid.UUID) error {
return s.repo.DeletePermission(ctx, id)
}
func (s *RBACServiceImpl) ListPermissions(ctx context.Context) (*contract.ListPermissionsResponse, error) {
perms, err := s.repo.ListPermissions(ctx)
if err != nil {
return nil, err
}
return &contract.ListPermissionsResponse{Permissions: transformer.PermissionsToContract(perms)}, nil
}
// Roles
func (s *RBACServiceImpl) CreateRole(ctx context.Context, req *contract.CreateRoleRequest) (*contract.RoleWithPermissionsResponse, error) {
role := &entities.Role{Name: req.Name, Code: req.Code}
if req.Description != nil {
role.Description = *req.Description
}
if err := s.repo.CreateRole(ctx, role); err != nil {
return nil, err
}
if len(req.PermissionCodes) > 0 {
_ = s.repo.SetRolePermissionsByCodes(ctx, role.ID, req.PermissionCodes)
}
perms, _ := s.repo.GetPermissionsByRoleID(ctx, role.ID)
resp := transformer.RoleWithPermissionsToContract(*role, perms)
return &resp, nil
}
func (s *RBACServiceImpl) UpdateRole(ctx context.Context, id uuid.UUID, req *contract.UpdateRoleRequest) (*contract.RoleWithPermissionsResponse, error) {
role := &entities.Role{ID: id}
if req.Name != nil {
role.Name = *req.Name
}
if req.Code != nil {
role.Code = *req.Code
}
if req.Description != nil {
role.Description = *req.Description
}
if err := s.repo.UpdateRole(ctx, role); err != nil {
return nil, err
}
if req.PermissionCodes != nil {
_ = s.repo.SetRolePermissionsByCodes(ctx, id, *req.PermissionCodes)
}
perms, _ := s.repo.GetPermissionsByRoleID(ctx, id)
// fetch updated role
roles, err := s.repo.ListRoles(ctx)
if err != nil {
return nil, err
}
for _, r := range roles {
if r.ID == id {
resp := transformer.RoleWithPermissionsToContract(r, perms)
return &resp, nil
}
}
return nil, nil
}
func (s *RBACServiceImpl) DeleteRole(ctx context.Context, id uuid.UUID) error {
return s.repo.DeleteRole(ctx, id)
}
func (s *RBACServiceImpl) ListRoles(ctx context.Context) (*contract.ListRolesResponse, error) {
roles, err := s.repo.ListRoles(ctx)
if err != nil {
return nil, err
}
out := make([]contract.RoleWithPermissionsResponse, 0, len(roles))
for _, r := range roles {
perms, _ := s.repo.GetPermissionsByRoleID(ctx, r.ID)
out = append(out, transformer.RoleWithPermissionsToContract(r, perms))
}
return &contract.ListRolesResponse{Roles: out}, nil
}

View File

@ -14,14 +14,22 @@ type UserProcessor interface {
DeleteUser(ctx context.Context, id uuid.UUID) error DeleteUser(ctx context.Context, id uuid.UUID) error
GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error)
GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error) GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error)
ListUsers(ctx context.Context, page, limit int) ([]contract.UserResponse, int, error)
GetUserEntityByEmail(ctx context.Context, email string) (*entities.User, error) GetUserEntityByEmail(ctx context.Context, email string) (*entities.User, error)
ChangePassword(ctx context.Context, userID uuid.UUID, req *contract.ChangePasswordRequest) error ChangePassword(ctx context.Context, userID uuid.UUID, req *contract.ChangePasswordRequest) error
GetUserRoles(ctx context.Context, userID uuid.UUID) ([]contract.RoleResponse, error) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]contract.RoleResponse, error)
GetUserPermissionCodes(ctx context.Context, userID uuid.UUID) ([]string, error) GetUserPermissionCodes(ctx context.Context, userID uuid.UUID) ([]string, error)
GetUserPositions(ctx context.Context, userID uuid.UUID) ([]contract.PositionResponse, error) GetUserDepartments(ctx context.Context, userID uuid.UUID) ([]contract.DepartmentResponse, error)
GetUserProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) GetUserProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error)
UpdateUserProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) UpdateUserProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error)
// 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)
// Bulk create users with transaction
BulkCreateUsersWithTransaction(ctx context.Context, users []contract.BulkUserRequest) ([]contract.UserResponse, []contract.BulkUserErrorResult, error)
} }

View File

@ -2,9 +2,12 @@ package service
import ( import (
"context" "context"
"fmt"
"sync"
"eslogad-be/internal/contract" "eslogad-be/internal/contract"
"eslogad-be/internal/entities" "eslogad-be/internal/entities"
"eslogad-be/internal/manager"
"eslogad-be/internal/transformer" "eslogad-be/internal/transformer"
"github.com/google/uuid" "github.com/google/uuid"
@ -30,6 +33,186 @@ func (s *UserServiceImpl) CreateUser(ctx context.Context, req *contract.CreateUs
return s.userProcessor.CreateUser(ctx, req) 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,
},
}
batchSize := 50
for i := 0; i < len(req.Users); i += batchSize {
end := i + batchSize
if end > len(req.Users) {
end = len(req.Users)
}
batch := req.Users[i:end]
batchResults, err := s.processBulkUserBatch(ctx, batch)
if err != nil {
for _, userReq := range batch {
response.Failed = append(response.Failed, contract.BulkUserErrorResult{
User: userReq,
Error: "Batch processing error: " + err.Error(),
})
response.Summary.Failed++
}
continue
}
response.Created = append(response.Created, batchResults.Created...)
response.Failed = append(response.Failed, batchResults.Failed...)
response.Summary.Succeeded += batchResults.Summary.Succeeded
response.Summary.Failed += batchResults.Summary.Failed
}
return response, nil
}
func (s *UserServiceImpl) BulkCreateUsersAsync(ctx context.Context, req *contract.BulkCreateUsersRequest) (*contract.BulkCreateAsyncResponse, error) {
jobManager := manager.GetJobManager()
jobID := jobManager.CreateJob()
// Start async processing
go s.processBulkUsersAsync(context.Background(), jobID, req)
return &contract.BulkCreateAsyncResponse{
JobID: jobID,
Message: fmt.Sprintf("Job started for %d users", len(req.Users)),
Status: "processing",
}, nil
}
func (s *UserServiceImpl) processBulkUsersAsync(ctx context.Context, jobID uuid.UUID, req *contract.BulkCreateUsersRequest) {
jobManager := manager.GetJobManager()
jobManager.UpdateJob(jobID, manager.JobStatusProcessing, fmt.Sprintf("Processing %d users", len(req.Users)))
batchSize := 50
var wg sync.WaitGroup
resultChan := make(chan *contract.BulkCreateUsersResponse, (len(req.Users)/batchSize)+1)
// Process each batch independently in its own goroutine
for i := 0; i < len(req.Users); i += batchSize {
end := i + batchSize
if end > len(req.Users) {
end = len(req.Users)
}
batch := req.Users[i:end]
wg.Add(1)
// Launch goroutine for each batch
go func(batchNum int, users []contract.BulkUserRequest) {
defer wg.Done()
batchResult := &contract.BulkCreateUsersResponse{
Created: []contract.UserResponse{},
Failed: []contract.BulkUserErrorResult{},
Summary: contract.BulkCreationSummary{
Total: len(users),
Succeeded: 0,
Failed: 0,
},
}
// Process batch
created, failed, err := s.userProcessor.BulkCreateUsersWithTransaction(ctx, users)
if err != nil {
// If entire batch fails, mark all users as failed
for _, userReq := range users {
batchResult.Failed = append(batchResult.Failed, contract.BulkUserErrorResult{
User: userReq,
Error: fmt.Sprintf("Batch %d error: %v", batchNum, err),
})
batchResult.Summary.Failed++
}
} else {
batchResult.Created = created
batchResult.Failed = failed
batchResult.Summary.Succeeded = len(created)
batchResult.Summary.Failed = len(failed)
}
resultChan <- batchResult
}(i/batchSize, batch)
}
// Wait for all batches to complete
go func() {
wg.Wait()
close(resultChan)
}()
// Aggregate results
totalSummary := contract.BulkCreationSummary{
Total: len(req.Users),
Succeeded: 0,
Failed: 0,
}
allCreated := []contract.UserResponse{}
allFailed := []contract.BulkUserErrorResult{}
for result := range resultChan {
allCreated = append(allCreated, result.Created...)
allFailed = append(allFailed, result.Failed...)
totalSummary.Succeeded += result.Summary.Succeeded
totalSummary.Failed += result.Summary.Failed
// Update job progress
jobManager.UpdateJobResults(jobID, result.Created, result.Failed, result.Summary)
}
// Mark job as completed
status := manager.JobStatusCompleted
message := fmt.Sprintf("Completed: %d succeeded, %d failed out of %d total",
totalSummary.Succeeded, totalSummary.Failed, totalSummary.Total)
if totalSummary.Failed == totalSummary.Total {
status = manager.JobStatusFailed
message = "All user creations failed"
}
jobManager.UpdateJob(jobID, status, message)
}
func (s *UserServiceImpl) GetBulkJobStatus(ctx context.Context, jobID uuid.UUID) (*manager.BulkJobResult, error) {
jobManager := manager.GetJobManager()
job, exists := jobManager.GetJob(jobID)
if !exists {
return nil, fmt.Errorf("job not found: %s", jobID)
}
return job, nil
}
func (s *UserServiceImpl) processBulkUserBatch(ctx context.Context, batch []contract.BulkUserRequest) (*contract.BulkCreateUsersResponse, error) {
response := &contract.BulkCreateUsersResponse{
Created: []contract.UserResponse{},
Failed: []contract.BulkUserErrorResult{},
Summary: contract.BulkCreationSummary{
Total: len(batch),
Succeeded: 0,
Failed: 0,
},
}
// Use transaction for batch processing
created, failed, err := s.userProcessor.BulkCreateUsersWithTransaction(ctx, batch)
if err != nil {
return response, err
}
response.Created = created
response.Failed = failed
response.Summary.Succeeded = len(created)
response.Summary.Failed = len(failed)
return response, nil
}
func (s *UserServiceImpl) UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) { func (s *UserServiceImpl) UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) {
return s.userProcessor.UpdateUser(ctx, id, req) return s.userProcessor.UpdateUser(ctx, id, req)
} }
@ -56,7 +239,7 @@ func (s *UserServiceImpl) ListUsers(ctx context.Context, req *contract.ListUsers
limit = 10 limit = 10
} }
userResponses, totalCount, err := s.userProcessor.ListUsers(ctx, page, limit) userResponses, totalCount, err := s.userProcessor.ListUsersWithFilters(ctx, req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -72,7 +255,14 @@ func (s *UserServiceImpl) ChangePassword(ctx context.Context, userID uuid.UUID,
} }
func (s *UserServiceImpl) GetProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) { func (s *UserServiceImpl) GetProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) {
return s.userProcessor.GetUserProfile(ctx, userID) prof, err := s.userProcessor.GetUserProfile(ctx, userID)
if err != nil {
return nil, err
}
if roles, err := s.userProcessor.GetUserRoles(ctx, userID); err == nil {
prof.Roles = roles
}
return prof, nil
} }
func (s *UserServiceImpl) UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) { func (s *UserServiceImpl) UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) {
@ -89,3 +279,8 @@ func (s *UserServiceImpl) ListTitles(ctx context.Context) (*contract.ListTitlesR
} }
return &contract.ListTitlesResponse{Titles: transformer.TitlesToContract(titles)}, nil return &contract.ListTitlesResponse{Titles: transformer.TitlesToContract(titles)}, nil
} }
// GetActiveUsersForMention retrieves active users for mention purposes
func (s *UserServiceImpl) GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) {
return s.userProcessor.GetActiveUsersForMention(ctx, search, limit)
}

View File

@ -0,0 +1,286 @@
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)
GetVotedCount(ctx context.Context, eventID uuid.UUID) (int64, error)
GetTotalActiveUsersCount(ctx context.Context) (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
}
func (s *VoteEventServiceImpl) GetVoteEventDetails(ctx context.Context, eventID uuid.UUID) (*contract.VoteEventDetailsResponse, error) {
voteEvent, err := s.voteEventRepo.GetByID(ctx, eventID)
if err != nil {
return nil, err
}
totalParticipants, err := s.voteEventRepo.GetTotalActiveUsersCount(ctx)
if err != nil {
return nil, err
}
totalVoted, err := s.voteEventRepo.GetVotedCount(ctx, eventID)
if err != nil {
return nil, err
}
totalNotVoted := totalParticipants - totalVoted
return &contract.VoteEventDetailsResponse{
VoteEvent: *transformer.VoteEventToContract(voteEvent),
TotalParticipants: totalParticipants,
TotalVoted: totalVoted,
TotalNotVoted: totalNotVoted,
}, nil
}

View File

@ -78,17 +78,21 @@ func RolesToContract(roles []entities.Role) []contract.RoleResponse {
return res return res
} }
func PositionsToContract(positions []entities.Position) []contract.PositionResponse { func DepartmentsToContract(positions []entities.Department) []contract.DepartmentResponse {
if positions == nil { if positions == nil {
return nil return nil
} }
res := make([]contract.PositionResponse, 0, len(positions)) res := make([]contract.DepartmentResponse, 0, len(positions))
for _, p := range positions { for _, p := range positions {
res = append(res, contract.PositionResponse{ID: p.ID, Name: p.Name, Code: p.Code, Path: p.Path}) res = append(res, contract.DepartmentResponse{ID: p.ID, Name: p.Name, Code: p.Code, Path: p.Path})
} }
return res return res
} }
func DepartmentToContract(p entities.Department) contract.DepartmentResponse {
return contract.DepartmentResponse{ID: p.ID, Name: p.Name, Code: p.Code, Path: p.Path}
}
func ProfileEntityToContract(p *entities.UserProfile) *contract.UserProfileResponse { func ProfileEntityToContract(p *entities.UserProfile) *contract.UserProfileResponse {
if p == nil { if p == nil {
return nil return nil
@ -170,3 +174,106 @@ func TitlesToContract(titles []entities.Title) []contract.TitleResponse {
} }
return out return out
} }
func PermissionsToContract(perms []entities.Permission) []contract.PermissionResponse {
out := make([]contract.PermissionResponse, 0, len(perms))
for _, p := range perms {
out = append(out, contract.PermissionResponse{ID: p.ID, Code: p.Code, Description: &p.Description, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt})
}
return out
}
func RoleWithPermissionsToContract(role entities.Role, perms []entities.Permission) contract.RoleWithPermissionsResponse {
return contract.RoleWithPermissionsResponse{
ID: role.ID,
Name: role.Name,
Code: role.Code,
Description: &role.Description,
Permissions: PermissionsToContract(perms),
CreatedAt: role.CreatedAt,
UpdatedAt: role.UpdatedAt,
}
}
func LabelsToContract(list []entities.Label) []contract.LabelResponse {
out := make([]contract.LabelResponse, 0, len(list))
for _, e := range list {
out = append(out, contract.LabelResponse{ID: e.ID.String(), Name: e.Name, Color: e.Color, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt})
}
return out
}
func PrioritiesToContract(list []entities.Priority) []contract.PriorityResponse {
out := make([]contract.PriorityResponse, 0, len(list))
for _, e := range list {
out = append(out, contract.PriorityResponse{ID: e.ID.String(), Name: e.Name, Level: e.Level, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt})
}
return out
}
func InstitutionsToContract(list []entities.Institution) []contract.InstitutionResponse {
out := make([]contract.InstitutionResponse, 0, len(list))
for _, e := range list {
out = append(out, contract.InstitutionResponse{ID: e.ID.String(), Name: e.Name, Type: string(e.Type), Address: e.Address, ContactPerson: e.ContactPerson, Phone: e.Phone, Email: e.Email, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt})
}
return out
}
func DispositionActionsToContract(list []entities.DispositionAction) []contract.DispositionActionResponse {
out := make([]contract.DispositionActionResponse, 0, len(list))
for _, e := range list {
out = append(out, contract.DispositionActionResponse{
ID: e.ID.String(),
Code: e.Code,
Label: e.Label,
Description: e.Description,
RequiresNote: e.RequiresNote,
GroupName: e.GroupName,
SortOrder: e.SortOrder,
IsActive: e.IsActive,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
})
}
return out
}
func DispositionRoutesToContract(list []entities.DispositionRoute) []contract.DispositionRouteResponse {
out := make([]contract.DispositionRouteResponse, 0, len(list))
for _, e := range list {
var allowed map[string]interface{}
if e.AllowedActions != nil {
allowed = map[string]interface{}(e.AllowedActions)
}
resp := contract.DispositionRouteResponse{
ID: e.ID,
FromDepartmentID: e.FromDepartmentID,
ToDepartmentID: e.ToDepartmentID,
IsActive: e.IsActive,
AllowedActions: allowed,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
// Add department information if available
if e.FromDepartment.ID != uuid.Nil {
resp.FromDepartment = contract.DepartmentInfo{
ID: e.FromDepartment.ID,
Name: e.FromDepartment.Name,
Code: e.FromDepartment.Code,
}
}
if e.ToDepartment.ID != uuid.Nil {
resp.ToDepartment = contract.DepartmentInfo{
ID: e.ToDepartment.ID,
Name: e.ToDepartment.Name,
Code: e.ToDepartment.Code,
}
}
out = append(out, resp)
}
return out
}

View File

@ -0,0 +1,388 @@
package transformer
import (
"eslogad-be/internal/contract"
"eslogad-be/internal/entities"
"github.com/google/uuid"
)
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,
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{
ID: a.ID,
FileURL: a.FileURL,
FileName: a.FileName,
FileType: a.FileType,
UploadedAt: a.UploadedAt,
})
}
return resp
}
func DispositionsToContract(list []entities.LetterIncomingDisposition) []contract.DispositionResponse {
out := make([]contract.DispositionResponse, 0, len(list))
for _, d := range list {
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,
DepartmentID: d.DepartmentID,
Notes: d.Notes,
ReadAt: d.ReadAt,
CreatedBy: d.CreatedBy,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
Departments: []contract.DispositionDepartmentResponse{},
Actions: []contract.DispositionActionSelectionResponse{},
DispositionNotes: []contract.DispositionNoteResponse{},
}
out = append(out, resp)
}
return out
}
func DispositionDepartmentsToContract(list []entities.LetterIncomingDispositionDepartment) []contract.DispositionDepartmentResponse {
out := make([]contract.DispositionDepartmentResponse, 0, len(list))
for _, d := range list {
resp := contract.DispositionDepartmentResponse{
ID: d.ID,
DepartmentID: d.DepartmentID,
CreatedAt: d.CreatedAt,
}
out = append(out, resp)
}
return out
}
func DispositionDepartmentsWithDetailsToContract(list []entities.LetterIncomingDispositionDepartment) []contract.DispositionDepartmentResponse {
out := make([]contract.DispositionDepartmentResponse, 0, len(list))
for _, d := range list {
resp := contract.DispositionDepartmentResponse{
ID: d.ID,
DepartmentID: d.DepartmentID,
CreatedAt: d.CreatedAt,
}
// Include department details if preloaded
if d.Department != nil {
resp.Department = &contract.DepartmentResponse{
ID: d.Department.ID,
Name: d.Department.Name,
Code: d.Department.Code,
Path: d.Department.Path,
}
}
out = append(out, resp)
}
return out
}
func DispositionActionSelectionsToContract(list []entities.LetterDispositionActionSelection) []contract.DispositionActionSelectionResponse {
out := make([]contract.DispositionActionSelectionResponse, 0, len(list))
for _, d := range list {
resp := contract.DispositionActionSelectionResponse{
ID: d.ID,
ActionID: d.ActionID,
Action: nil, // Will be populated by processor
Note: d.Note,
CreatedBy: d.CreatedBy,
CreatedAt: d.CreatedAt,
}
out = append(out, resp)
}
return out
}
func DispositionActionSelectionsWithDetailsToContract(list []entities.LetterDispositionActionSelection) []contract.DispositionActionSelectionResponse {
out := make([]contract.DispositionActionSelectionResponse, 0, len(list))
for _, d := range list {
resp := contract.DispositionActionSelectionResponse{
ID: d.ID,
ActionID: d.ActionID,
Action: nil, // Will be populated by processor
Note: d.Note,
CreatedBy: d.CreatedBy,
CreatedAt: d.CreatedAt,
}
// Include action details if preloaded
if d.Action != nil {
resp.Action = &contract.DispositionActionResponse{
ID: d.Action.ID.String(),
Code: d.Action.Code,
Label: d.Action.Label,
Description: d.Action.Description,
RequiresNote: d.Action.RequiresNote,
GroupName: d.Action.GroupName,
SortOrder: d.Action.SortOrder,
IsActive: d.Action.IsActive,
CreatedAt: d.Action.CreatedAt,
UpdatedAt: d.Action.UpdatedAt,
}
}
out = append(out, resp)
}
return out
}
func DispositionNotesToContract(list []entities.DispositionNote) []contract.DispositionNoteResponse {
out := make([]contract.DispositionNoteResponse, 0, len(list))
for _, d := range list {
resp := contract.DispositionNoteResponse{
ID: d.ID,
UserID: d.UserID,
Note: d.Note,
CreatedAt: d.CreatedAt,
}
out = append(out, resp)
}
return out
}
func DispositionNotesWithDetailsToContract(list []entities.DispositionNote) []contract.DispositionNoteResponse {
out := make([]contract.DispositionNoteResponse, 0, len(list))
for _, d := range list {
resp := contract.DispositionNoteResponse{
ID: d.ID,
UserID: d.UserID,
Note: d.Note,
CreatedAt: d.CreatedAt,
}
// Include user details if preloaded
if d.User != nil {
resp.User = &contract.UserResponse{
ID: d.User.ID,
Name: d.User.Name,
Email: d.User.Email,
IsActive: d.User.IsActive,
CreatedAt: d.User.CreatedAt,
UpdatedAt: d.User.UpdatedAt,
}
}
out = append(out, resp)
}
return out
}
func DiscussionEntityToContract(e *entities.LetterDiscussion) *contract.LetterDiscussionResponse {
var mentions map[string]interface{}
if e.Mentions != nil {
mentions = map[string]interface{}(e.Mentions)
}
return &contract.LetterDiscussionResponse{
ID: e.ID,
LetterID: e.LetterID,
ParentID: e.ParentID,
UserID: e.UserID,
Message: e.Message,
Mentions: mentions,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
EditedAt: e.EditedAt,
}
}
func DiscussionsWithPreloadedDataToContract(list []entities.LetterDiscussion, mentionedUsers []entities.User) []contract.LetterDiscussionResponse {
// Create a map for efficient user lookup
userMap := make(map[uuid.UUID]entities.User)
for _, user := range mentionedUsers {
userMap[user.ID] = user
}
out := make([]contract.LetterDiscussionResponse, 0, len(list))
for _, d := range list {
resp := contract.LetterDiscussionResponse{
ID: d.ID,
LetterID: d.LetterID,
ParentID: d.ParentID,
UserID: d.UserID,
Message: d.Message,
Mentions: map[string]interface{}(d.Mentions),
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
EditedAt: d.EditedAt,
}
// Include user profile if preloaded
if d.User != nil {
resp.User = &contract.UserResponse{
ID: d.User.ID,
Name: d.User.Name,
Email: d.User.Email,
IsActive: d.User.IsActive,
CreatedAt: d.User.CreatedAt,
UpdatedAt: d.User.UpdatedAt,
}
// Include user profile if available
if d.User.Profile != nil {
resp.User.Profile = &contract.UserProfileResponse{
UserID: d.User.Profile.UserID,
FullName: d.User.Profile.FullName,
DisplayName: d.User.Profile.DisplayName,
Phone: d.User.Profile.Phone,
AvatarURL: d.User.Profile.AvatarURL,
JobTitle: d.User.Profile.JobTitle,
EmployeeNo: d.User.Profile.EmployeeNo,
Bio: d.User.Profile.Bio,
Timezone: d.User.Profile.Timezone,
Locale: d.User.Profile.Locale,
}
}
}
// Process mentions to get mentioned users with profiles
if d.Mentions != nil {
mentions := map[string]interface{}(d.Mentions)
if userIDs, ok := mentions["user_ids"]; ok {
if userIDList, ok := userIDs.([]interface{}); ok {
mentionedUsersList := make([]contract.UserResponse, 0)
for _, userID := range userIDList {
if userIDStr, ok := userID.(string); ok {
if userUUID, err := uuid.Parse(userIDStr); err == nil {
if user, exists := userMap[userUUID]; exists {
userResp := contract.UserResponse{
ID: user.ID,
Name: user.Name,
Email: user.Email,
IsActive: user.IsActive,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
// Include user profile if available
if user.Profile != nil {
userResp.Profile = &contract.UserProfileResponse{
UserID: user.Profile.UserID,
FullName: user.Profile.FullName,
DisplayName: user.Profile.DisplayName,
Phone: user.Profile.Phone,
AvatarURL: user.Profile.AvatarURL,
JobTitle: user.Profile.JobTitle,
EmployeeNo: user.Profile.EmployeeNo,
Bio: user.Profile.Bio,
Timezone: user.Profile.Timezone,
Locale: user.Profile.Locale,
}
}
mentionedUsersList = append(mentionedUsersList, userResp)
}
}
}
}
resp.MentionedUsers = mentionedUsersList
}
}
}
out = append(out, resp)
}
return out
}
func EnhancedDispositionsWithPreloadedDataToContract(list []entities.LetterIncomingDisposition) []contract.EnhancedDispositionResponse {
out := make([]contract.EnhancedDispositionResponse, 0, len(list))
for _, d := range list {
resp := contract.EnhancedDispositionResponse{
ID: d.ID,
LetterID: d.LetterID,
DepartmentID: d.DepartmentID,
Notes: d.Notes,
ReadAt: d.ReadAt,
CreatedBy: d.CreatedBy,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
Departments: []contract.DispositionDepartmentResponse{},
Actions: []contract.DispositionActionSelectionResponse{},
DispositionNotes: []contract.DispositionNoteResponse{},
Department: DepartmentToContract(d.Department),
}
if len(d.Departments) > 0 {
resp.Departments = DispositionDepartmentsWithDetailsToContract(d.Departments)
}
// Include preloaded action selections with details
if len(d.ActionSelections) > 0 {
resp.Actions = DispositionActionSelectionsWithDetailsToContract(d.ActionSelections)
}
// Include preloaded notes with user details
if len(d.DispositionNotes) > 0 {
resp.DispositionNotes = DispositionNotesWithDetailsToContract(d.DispositionNotes)
}
out = append(out, resp)
}
return out
}

View File

@ -10,6 +10,7 @@ func CreateUserRequestToEntity(req *contract.CreateUserRequest, passwordHash str
return nil return nil
} }
return &entities.User{ return &entities.User{
Username: req.Email,
Name: req.Name, Name: req.Name,
Email: req.Email, Email: req.Email,
PasswordHash: passwordHash, PasswordHash: passwordHash,
@ -26,6 +27,7 @@ func UpdateUserEntity(existing *entities.User, req *contract.UpdateUserRequest)
} }
if req.Email != nil { if req.Email != nil {
existing.Email = *req.Email existing.Email = *req.Email
existing.Username = *req.Email
} }
if req.IsActive != nil { if req.IsActive != nil {
existing.IsActive = *req.IsActive existing.IsActive = *req.IsActive
@ -37,14 +39,23 @@ func EntityToContract(user *entities.User) *contract.UserResponse {
if user == nil { if user == nil {
return nil return nil
} }
return &contract.UserResponse{
resp := &contract.UserResponse{
ID: user.ID, ID: user.ID,
Username: user.Username,
Name: user.Name, Name: user.Name,
Email: user.Email, Email: user.Email,
IsActive: user.IsActive, IsActive: user.IsActive,
CreatedAt: user.CreatedAt, CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt, UpdatedAt: user.UpdatedAt,
} }
if user.Profile != nil {
resp.Profile = ProfileEntityToContract(user.Profile)
}
if user.Departments != nil && len(user.Departments) > 0 {
resp.DepartmentResponse = DepartmentsToContract(user.Departments)
}
return resp
} }
func EntitiesToContracts(users []*entities.User) []contract.UserResponse { func EntitiesToContracts(users []*entities.User) []contract.UserResponse {

View File

@ -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,
}
}

View File

@ -1,18 +1,5 @@
BEGIN; BEGIN;
-- =========================
-- Departments (as requested)
-- =========================
-- Root org namespace is "eslogad" in ltree path
INSERT INTO departments (name, code, path) VALUES
('RENBINMINLOG', 'renbinminlog', 'eslogad.renbinminlog'),
('FASKON BMN', 'faskon_bmn', 'eslogad.faskon_bmn'),
('BEKPALKES', 'bekpalkes', 'eslogad.bekpalkes')
ON CONFLICT (code) DO UPDATE
SET name = EXCLUDED.name,
path = EXCLUDED.path,
updated_at = CURRENT_TIMESTAMP;
-- ========================= -- =========================
-- Positions (hierarchy) -- Positions (hierarchy)
-- ========================= -- =========================
@ -20,7 +7,7 @@ INSERT INTO departments (name, code, path) VALUES
-- - superadmin is a separate root -- - superadmin is a separate root
-- - eslogad.aslog is head; waaslog_* under aslog -- - eslogad.aslog is head; waaslog_* under aslog
-- - paban_* under each waaslog_*; pabandya_* under its paban_* -- - paban_* under each waaslog_*; pabandya_* under its paban_*
INSERT INTO positions (name, code, path) VALUES INSERT INTO departments (name, code, path) VALUES
-- ROOTS -- ROOTS
('SUPERADMIN', 'superadmin', 'superadmin'), ('SUPERADMIN', 'superadmin', 'superadmin'),
('ASLOG', 'aslog', 'eslogad.aslog'), ('ASLOG', 'aslog', 'eslogad.aslog'),

View File

@ -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');

View File

@ -0,0 +1,5 @@
-- Drop Voting System Tables
DROP TABLE IF EXISTS votes;
DROP TABLE IF EXISTS candidates;
DROP TABLE IF EXISTS vote_events;

View File

@ -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);

View File

@ -0,0 +1,3 @@
-- Remove results_open column from vote_events table
ALTER TABLE vote_events
DROP COLUMN IF EXISTS results_open;

View File

@ -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;

View File

@ -0,0 +1,8 @@
-- Drop performance indexes
DROP INDEX IF EXISTS idx_users_name_trgm;
DROP INDEX IF EXISTS idx_users_email_trgm;
DROP INDEX IF EXISTS idx_users_created_at;
DROP INDEX IF EXISTS idx_users_active_name;
DROP INDEX IF EXISTS idx_users_is_active;
DROP INDEX IF EXISTS idx_users_name;
DROP INDEX IF EXISTS idx_users_email;

View File

@ -0,0 +1,20 @@
-- Add performance indexes for user queries
-- Index for email lookup (already exists as unique, but let's ensure it's there)
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- Index for name searches
CREATE INDEX IF NOT EXISTS idx_users_name ON users(name);
-- Index for active status filtering
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);
-- Composite index for common query patterns
CREATE INDEX IF NOT EXISTS idx_users_active_name ON users(is_active, name);
-- Index for created_at for sorting
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at DESC);
-- GIN index for full-text search on name and email
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_users_name_trgm ON users USING gin (name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_users_email_trgm ON users USING gin (email gin_trgm_ops);

BIN
server Executable file

Binary file not shown.