This commit is contained in:
Aditya Siregar 2025-08-15 21:17:19 +07:00
parent de60983e4e
commit 826c5d26ad
65 changed files with 2625 additions and 669 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"
DB_USERNAME :=eslogad_user
DB_PASSWORD :=M9u%24e%23jT2%40qR4pX%21zL
DB_USERNAME :=metidb
DB_PASSWORD :=metipassword%23123
DB_HOST :=103.191.71.2
DB_PORT :=5432
DB_NAME :=eslogad_db
DB_PORT :=5433
DB_NAME :=mydb
DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable

View File

@ -1,7 +1,7 @@
#!/bin/bash
APP_NAME="eslogad"
PORT="4000"
APP_NAME="meti-backend"
PORT="4001"
echo "🔄 Pulling latest code..."
git pull

4
go.mod
View File

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

6
go.sum
View File

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

View File

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

View File

@ -47,6 +47,7 @@ func (a *App) Initialize(cfg *config.Config) error {
masterHandler := handler.NewMasterHandler(services.masterService)
letterHandler := handler.NewLetterHandler(services.letterService)
dispositionRouteHandler := handler.NewDispositionRouteHandler(services.dispositionRouteService)
voteEventHandler := handler.NewVoteEventHandler(services.voteEventService)
a.router = router.NewRouter(
cfg,
@ -59,6 +60,7 @@ func (a *App) Initialize(cfg *config.Config) error {
masterHandler,
letterHandler,
dispositionRouteHandler,
voteEventHandler,
)
return nil
@ -117,38 +119,42 @@ type repositories struct {
activityLogRepo *repository.LetterIncomingActivityLogRepository
dispositionRouteRepo *repository.DispositionRouteRepository
// new repos
letterDispositionRepo *repository.LetterDispositionRepository
letterDispActionSelRepo *repository.LetterDispositionActionSelectionRepository
dispositionNoteRepo *repository.DispositionNoteRepository
letterDiscussionRepo *repository.LetterDiscussionRepository
settingRepo *repository.AppSettingRepository
recipientRepo *repository.LetterIncomingRecipientRepository
departmentRepo *repository.DepartmentRepository
userDeptRepo *repository.UserDepartmentRepository
letterDispositionRepo *repository.LetterIncomingDispositionRepository
letterDispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository
letterDispActionSelRepo *repository.LetterDispositionActionSelectionRepository
dispositionNoteRepo *repository.DispositionNoteRepository
letterDiscussionRepo *repository.LetterDiscussionRepository
settingRepo *repository.AppSettingRepository
recipientRepo *repository.LetterIncomingRecipientRepository
departmentRepo *repository.DepartmentRepository
userDeptRepo *repository.UserDepartmentRepository
voteEventRepo *repository.VoteEventRepositoryImpl
}
func (a *App) initRepositories() *repositories {
return &repositories{
userRepo: repository.NewUserRepository(a.db),
userProfileRepo: repository.NewUserProfileRepository(a.db),
titleRepo: repository.NewTitleRepository(a.db),
rbacRepo: repository.NewRBACRepository(a.db),
labelRepo: repository.NewLabelRepository(a.db),
priorityRepo: repository.NewPriorityRepository(a.db),
institutionRepo: repository.NewInstitutionRepository(a.db),
dispRepo: repository.NewDispositionActionRepository(a.db),
letterRepo: repository.NewLetterIncomingRepository(a.db),
letterAttachRepo: repository.NewLetterIncomingAttachmentRepository(a.db),
activityLogRepo: repository.NewLetterIncomingActivityLogRepository(a.db),
dispositionRouteRepo: repository.NewDispositionRouteRepository(a.db),
letterDispositionRepo: repository.NewLetterDispositionRepository(a.db),
letterDispActionSelRepo: repository.NewLetterDispositionActionSelectionRepository(a.db),
dispositionNoteRepo: repository.NewDispositionNoteRepository(a.db),
letterDiscussionRepo: repository.NewLetterDiscussionRepository(a.db),
settingRepo: repository.NewAppSettingRepository(a.db),
recipientRepo: repository.NewLetterIncomingRecipientRepository(a.db),
departmentRepo: repository.NewDepartmentRepository(a.db),
userDeptRepo: repository.NewUserDepartmentRepository(a.db),
userRepo: repository.NewUserRepository(a.db),
userProfileRepo: repository.NewUserProfileRepository(a.db),
titleRepo: repository.NewTitleRepository(a.db),
rbacRepo: repository.NewRBACRepository(a.db),
labelRepo: repository.NewLabelRepository(a.db),
priorityRepo: repository.NewPriorityRepository(a.db),
institutionRepo: repository.NewInstitutionRepository(a.db),
dispRepo: repository.NewDispositionActionRepository(a.db),
letterRepo: repository.NewLetterIncomingRepository(a.db),
letterAttachRepo: repository.NewLetterIncomingAttachmentRepository(a.db),
activityLogRepo: repository.NewLetterIncomingActivityLogRepository(a.db),
dispositionRouteRepo: repository.NewDispositionRouteRepository(a.db),
letterDispositionRepo: repository.NewLetterIncomingDispositionRepository(a.db),
letterDispositionDeptRepo: repository.NewLetterIncomingDispositionDepartmentRepository(a.db),
letterDispActionSelRepo: repository.NewLetterDispositionActionSelectionRepository(a.db),
dispositionNoteRepo: repository.NewDispositionNoteRepository(a.db),
letterDiscussionRepo: repository.NewLetterDiscussionRepository(a.db),
settingRepo: repository.NewAppSettingRepository(a.db),
recipientRepo: repository.NewLetterIncomingRecipientRepository(a.db),
departmentRepo: repository.NewDepartmentRepository(a.db),
userDeptRepo: repository.NewUserDepartmentRepository(a.db),
voteEventRepo: repository.NewVoteEventRepository(a.db),
}
}
@ -163,7 +169,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
activity := processor.NewActivityLogProcessor(repos.activityLogRepo)
return &processors{
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo),
letterProcessor: processor.NewLetterProcessor(repos.letterRepo, repos.letterAttachRepo, txMgr, activity, repos.letterDispositionRepo, repos.letterDispActionSelRepo, repos.dispositionNoteRepo, repos.letterDiscussionRepo, repos.settingRepo, repos.recipientRepo, repos.departmentRepo, repos.userDeptRepo),
letterProcessor: processor.NewLetterProcessor(repos.letterRepo, repos.letterAttachRepo, txMgr, activity, repos.letterDispositionRepo, repos.letterDispositionDeptRepo, repos.letterDispActionSelRepo, repos.dispositionNoteRepo, repos.letterDiscussionRepo, repos.settingRepo, repos.recipientRepo, repos.departmentRepo, repos.userDeptRepo, repos.priorityRepo, repos.institutionRepo, repos.dispRepo),
activityLogger: activity,
}
}
@ -176,6 +182,7 @@ type services struct {
masterService *service.MasterServiceImpl
letterService *service.LetterServiceImpl
dispositionRouteService *service.DispositionRouteServiceImpl
voteEventService *service.VoteEventServiceImpl
}
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
@ -195,6 +202,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
letterSvc := service.NewLetterService(processors.letterProcessor)
dispRouteSvc := service.NewDispositionRouteService(repos.dispositionRouteRepo)
voteEventSvc := service.NewVoteEventService(repos.voteEventRepo)
return &services{
userService: userSvc,
@ -204,6 +212,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
masterService: masterSvc,
letterService: letterSvc,
dispositionRouteService: dispRouteSvc,
voteEventService: voteEventSvc,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -48,14 +48,15 @@ type LoginResponse struct {
}
type UserResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Roles []RoleResponse `json:"roles,omitempty"`
Profile *UserProfileResponse `json:"profile,omitempty"`
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Roles []RoleResponse `json:"roles,omitempty"`
DepartmentResponse []DepartmentResponse `json:"department_response"`
Profile *UserProfileResponse `json:"profile,omitempty"`
}
type ListUsersRequest struct {
@ -128,3 +129,48 @@ type TitleResponse struct {
type ListTitlesResponse struct {
Titles []TitleResponse `json:"titles"`
}
// MentionUsersRequest represents the request for getting users for mention purposes
type MentionUsersRequest struct {
Search *string `json:"search,omitempty" form:"search"` // Optional search term for username
Limit *int `json:"limit,omitempty" form:"limit"` // Optional limit, defaults to 50, max 100
}
// MentionUsersResponse represents the response for getting users for mention purposes
type MentionUsersResponse struct {
Users []UserResponse `json:"users"`
Count int `json:"count"`
}
// BulkCreateUsersRequest represents the request for creating multiple users
type BulkCreateUsersRequest struct {
Users []BulkUserRequest `json:"users" validate:"required,min=1,max=100"`
}
// BulkUserRequest represents a single user in bulk creation request
type BulkUserRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
Role string `json:"role" validate:"required"`
}
// BulkCreateUsersResponse represents the response for bulk user creation
type BulkCreateUsersResponse struct {
Created []UserResponse `json:"created"`
Failed []BulkUserErrorResult `json:"failed"`
Summary BulkCreationSummary `json:"summary"`
}
// BulkUserErrorResult represents a failed user creation with error details
type BulkUserErrorResult struct {
User BulkUserRequest `json:"user"`
Error string `json:"error"`
}
// BulkCreationSummary provides summary of bulk creation results
type BulkCreationSummary struct {
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
}

View File

@ -0,0 +1,96 @@
package contract
import (
"time"
"github.com/google/uuid"
)
type CreateVoteEventRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Description string `json:"description"`
StartDate time.Time `json:"start_date" validate:"required"`
EndDate time.Time `json:"end_date" validate:"required"`
ResultsOpen *bool `json:"results_open,omitempty"`
}
type UpdateVoteEventRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Description string `json:"description"`
StartDate time.Time `json:"start_date" validate:"required"`
EndDate time.Time `json:"end_date" validate:"required"`
IsActive bool `json:"is_active"`
ResultsOpen *bool `json:"results_open,omitempty"`
}
type VoteEventResponse struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
IsActive bool `json:"is_active"`
ResultsOpen bool `json:"results_open"`
IsVotingOpen bool `json:"is_voting_open"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Candidates []CandidateResponse `json:"candidates,omitempty"`
}
type ListVoteEventsRequest struct {
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
}
type ListVoteEventsResponse struct {
VoteEvents []VoteEventResponse `json:"vote_events"`
Pagination PaginationResponse `json:"pagination"`
}
type CreateCandidateRequest struct {
VoteEventID uuid.UUID `json:"vote_event_id" validate:"required"`
Name string `json:"name" validate:"required,min=1,max=255"`
ImageURL string `json:"image_url"`
Description string `json:"description"`
}
type CandidateResponse struct {
ID uuid.UUID `json:"id"`
VoteEventID uuid.UUID `json:"vote_event_id"`
Name string `json:"name"`
ImageURL string `json:"image_url"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
VoteCount int64 `json:"vote_count,omitempty"`
}
type SubmitVoteRequest struct {
VoteEventID uuid.UUID `json:"vote_event_id" validate:"required"`
CandidateID uuid.UUID `json:"candidate_id" validate:"required"`
}
type VoteResponse struct {
ID uuid.UUID `json:"id"`
VoteEventID uuid.UUID `json:"vote_event_id"`
CandidateID uuid.UUID `json:"candidate_id"`
UserID uuid.UUID `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
}
type VoteResultsResponse struct {
VoteEventID uuid.UUID `json:"vote_event_id"`
Candidates []CandidateWithVotesResponse `json:"candidates"`
TotalVotes int64 `json:"total_votes"`
}
type CandidateWithVotesResponse struct {
CandidateResponse
VoteCount int64 `json:"vote_count"`
}
type CheckVoteStatusResponse struct {
HasVoted bool `json:"has_voted"`
VotedAt *time.Time `json:"voted_at,omitempty"`
CandidateID *uuid.UUID `json:"candidate_id,omitempty"`
}

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@ -51,6 +51,43 @@ func (h *UserHandler) CreateUser(c *gin.Context) {
c.JSON(http.StatusCreated, userResponse)
}
func (h *UserHandler) BulkCreateUsers(c *gin.Context) {
var req contract.BulkCreateUsersRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::BulkCreateUsers -> request binding failed")
h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode)
return
}
if len(req.Users) == 0 {
h.sendValidationErrorResponse(c, "Users list cannot be empty", constants.MissingFieldErrorCode)
return
}
if len(req.Users) > 100 {
h.sendValidationErrorResponse(c, "Cannot create more than 100 users at once", constants.MissingFieldErrorCode)
return
}
response, err := h.userService.BulkCreateUsers(c.Request.Context(), &req)
if err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::BulkCreateUsers -> Failed to bulk create users")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
statusCode := http.StatusCreated
if response.Summary.Failed > 0 && response.Summary.Succeeded == 0 {
statusCode = http.StatusBadRequest
} else if response.Summary.Failed > 0 {
statusCode = http.StatusMultiStatus
}
logger.FromContext(c).Infof("UserHandler::BulkCreateUsers -> Successfully processed bulk creation: %d succeeded, %d failed",
response.Summary.Succeeded, response.Summary.Failed)
c.JSON(statusCode, contract.BuildSuccessResponse(response))
}
func (h *UserHandler) UpdateUser(c *gin.Context) {
userIDStr := c.Param("id")
userID, err := uuid.Parse(userIDStr)
@ -285,12 +322,48 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
}
func (h *UserHandler) ListTitles(c *gin.Context) {
resp, err := h.userService.ListTitles(c.Request.Context())
titles, err := h.userService.ListTitles(c.Request.Context())
if err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::ListTitles -> Failed to get titles from service")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
logger.FromContext(c).Infof("UserHandler::ListTitles -> Successfully retrieved titles = %+v", titles)
c.JSON(http.StatusOK, titles)
}
func (h *UserHandler) GetActiveUsersForMention(c *gin.Context) {
search := c.Query("search")
limitStr := c.DefaultQuery("limit", "50")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 50
}
if limit > 100 {
limit = 100
}
var searchPtr *string
if search != "" {
searchPtr = &search
}
users, err := h.userService.GetActiveUsersForMention(c.Request.Context(), searchPtr, limit)
if err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::GetActiveUsersForMention -> Failed to get active users from service")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
response := contract.MentionUsersResponse{
Users: users,
Count: len(users),
}
logger.FromContext(c).Infof("UserHandler::GetActiveUsersForMention -> Successfully retrieved %d active users", len(users))
c.JSON(http.StatusOK, response)
}
func (h *UserHandler) sendErrorResponse(c *gin.Context, message string, statusCode int) {

View File

@ -9,6 +9,7 @@ import (
type UserService interface {
CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error)
BulkCreateUsers(ctx context.Context, req *contract.BulkCreateUsersRequest) (*contract.BulkCreateUsersResponse, error)
UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error)
DeleteUser(ctx context.Context, id uuid.UUID) error
GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error)
@ -20,4 +21,6 @@ type UserService interface {
UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error)
ListTitles(ctx context.Context) (*contract.ListTitlesResponse, error)
GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error)
}

View File

@ -0,0 +1,323 @@
package handler
import (
"context"
"net/http"
"strconv"
"eslogad-be/internal/appcontext"
"eslogad-be/internal/constants"
"eslogad-be/internal/contract"
"eslogad-be/internal/logger"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type VoteEventService interface {
CreateVoteEvent(ctx context.Context, req *contract.CreateVoteEventRequest) (*contract.VoteEventResponse, error)
GetVoteEventByID(ctx context.Context, id uuid.UUID) (*contract.VoteEventResponse, error)
GetActiveEvents(ctx context.Context) ([]contract.VoteEventResponse, error)
ListVoteEvents(ctx context.Context, req *contract.ListVoteEventsRequest) (*contract.ListVoteEventsResponse, error)
UpdateVoteEvent(ctx context.Context, id uuid.UUID, req *contract.UpdateVoteEventRequest) (*contract.VoteEventResponse, error)
DeleteVoteEvent(ctx context.Context, id uuid.UUID) error
CreateCandidate(ctx context.Context, req *contract.CreateCandidateRequest) (*contract.CandidateResponse, error)
GetCandidates(ctx context.Context, eventID uuid.UUID) ([]contract.CandidateResponse, error)
SubmitVote(ctx context.Context, userID uuid.UUID, req *contract.SubmitVoteRequest) (*contract.VoteResponse, error)
GetVoteResults(ctx context.Context, eventID uuid.UUID) (*contract.VoteResultsResponse, error)
CheckVoteStatus(ctx context.Context, userID, eventID uuid.UUID) (*contract.CheckVoteStatusResponse, error)
}
type VoteEventHandler struct {
voteEventService VoteEventService
}
func NewVoteEventHandler(voteEventService VoteEventService) *VoteEventHandler {
return &VoteEventHandler{
voteEventService: voteEventService,
}
}
func (h *VoteEventHandler) CreateVoteEvent(c *gin.Context) {
var req contract.CreateVoteEventRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::CreateVoteEvent -> request binding failed")
h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode)
return
}
voteEventResponse, err := h.voteEventService.CreateVoteEvent(c.Request.Context(), &req)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::CreateVoteEvent -> Failed to create vote event")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
logger.FromContext(c).Infof("VoteEventHandler::CreateVoteEvent -> Successfully created vote event = %+v", voteEventResponse)
c.JSON(http.StatusCreated, contract.BuildSuccessResponse(voteEventResponse))
}
func (h *VoteEventHandler) GetVoteEvent(c *gin.Context) {
eventIDStr := c.Param("id")
eventID, err := uuid.Parse(eventIDStr)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetVoteEvent -> Invalid event ID")
h.sendValidationErrorResponse(c, "Invalid event ID", constants.MalformedFieldErrorCode)
return
}
voteEventResponse, err := h.voteEventService.GetVoteEventByID(c.Request.Context(), eventID)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetVoteEvent -> Failed to get vote event")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
logger.FromContext(c).Infof("VoteEventHandler::GetVoteEvent -> Successfully retrieved vote event = %+v", voteEventResponse)
c.JSON(http.StatusOK, contract.BuildSuccessResponse(voteEventResponse))
}
func (h *VoteEventHandler) GetActiveEvents(c *gin.Context) {
events, err := h.voteEventService.GetActiveEvents(c.Request.Context())
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetActiveEvents -> Failed to get active events")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
logger.FromContext(c).Infof("VoteEventHandler::GetActiveEvents -> Successfully retrieved %d active events", len(events))
c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{
"events": events,
"count": len(events),
}))
}
func (h *VoteEventHandler) ListVoteEvents(c *gin.Context) {
req := &contract.ListVoteEventsRequest{
Page: 1,
Limit: 10,
}
if page := c.Query("page"); page != "" {
if p, err := strconv.Atoi(page); err == nil && p > 0 {
req.Page = p
}
}
if limit := c.Query("limit"); limit != "" {
if l, err := strconv.Atoi(limit); err == nil && l > 0 && l <= 100 {
req.Limit = l
}
}
voteEventsResponse, err := h.voteEventService.ListVoteEvents(c.Request.Context(), req)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::ListVoteEvents -> Failed to list vote events")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
logger.FromContext(c).Infof("VoteEventHandler::ListVoteEvents -> Successfully listed vote events")
c.JSON(http.StatusOK, contract.BuildSuccessResponse(voteEventsResponse))
}
func (h *VoteEventHandler) UpdateVoteEvent(c *gin.Context) {
eventIDStr := c.Param("id")
eventID, err := uuid.Parse(eventIDStr)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::UpdateVoteEvent -> Invalid event ID")
h.sendValidationErrorResponse(c, "Invalid event ID", constants.MalformedFieldErrorCode)
return
}
var req contract.UpdateVoteEventRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::UpdateVoteEvent -> request binding failed")
h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode)
return
}
voteEventResponse, err := h.voteEventService.UpdateVoteEvent(c.Request.Context(), eventID, &req)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::UpdateVoteEvent -> Failed to update vote event")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
logger.FromContext(c).Infof("VoteEventHandler::UpdateVoteEvent -> Successfully updated vote event = %+v", voteEventResponse)
c.JSON(http.StatusOK, contract.BuildSuccessResponse(voteEventResponse))
}
func (h *VoteEventHandler) DeleteVoteEvent(c *gin.Context) {
eventIDStr := c.Param("id")
eventID, err := uuid.Parse(eventIDStr)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::DeleteVoteEvent -> Invalid event ID")
h.sendValidationErrorResponse(c, "Invalid event ID", constants.MalformedFieldErrorCode)
return
}
err = h.voteEventService.DeleteVoteEvent(c.Request.Context(), eventID)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::DeleteVoteEvent -> Failed to delete vote event")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
logger.FromContext(c).Info("VoteEventHandler::DeleteVoteEvent -> Successfully deleted vote event")
c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]string{
"message": "Vote event deleted successfully",
}))
}
func (h *VoteEventHandler) CreateCandidate(c *gin.Context) {
var req contract.CreateCandidateRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::CreateCandidate -> request binding failed")
h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode)
return
}
candidateResponse, err := h.voteEventService.CreateCandidate(c.Request.Context(), &req)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::CreateCandidate -> Failed to create candidate")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
logger.FromContext(c).Infof("VoteEventHandler::CreateCandidate -> Successfully created candidate = %+v", candidateResponse)
c.JSON(http.StatusCreated, contract.BuildSuccessResponse(candidateResponse))
}
func (h *VoteEventHandler) GetCandidates(c *gin.Context) {
eventIDStr := c.Param("id")
eventID, err := uuid.Parse(eventIDStr)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetCandidates -> Invalid event ID")
h.sendValidationErrorResponse(c, "Invalid event ID", constants.MalformedFieldErrorCode)
return
}
candidates, err := h.voteEventService.GetCandidates(c.Request.Context(), eventID)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetCandidates -> Failed to get candidates")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
logger.FromContext(c).Infof("VoteEventHandler::GetCandidates -> Successfully retrieved %d candidates", len(candidates))
c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{
"candidates": candidates,
"count": len(candidates),
}))
}
func (h *VoteEventHandler) SubmitVote(c *gin.Context) {
appCtx := appcontext.FromGinContext(c.Request.Context())
if appCtx.UserID == uuid.Nil {
h.sendErrorResponse(c, "Unauthorized", http.StatusUnauthorized)
return
}
var req contract.SubmitVoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::SubmitVote -> request binding failed")
h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode)
return
}
voteResponse, err := h.voteEventService.SubmitVote(c.Request.Context(), appCtx.UserID, &req)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::SubmitVote -> Failed to submit vote")
h.sendErrorResponse(c, err.Error(), http.StatusBadRequest)
return
}
logger.FromContext(c).Infof("VoteEventHandler::SubmitVote -> Successfully submitted vote = %+v", voteResponse)
c.JSON(http.StatusCreated, contract.BuildSuccessResponse(voteResponse))
}
func (h *VoteEventHandler) GetVoteResults(c *gin.Context) {
eventIDStr := c.Param("id")
eventID, err := uuid.Parse(eventIDStr)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetVoteResults -> Invalid event ID")
h.sendValidationErrorResponse(c, "Invalid event ID", constants.MalformedFieldErrorCode)
return
}
voteEvent, err := h.voteEventService.GetVoteEventByID(c.Request.Context(), eventID)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetVoteResults -> Failed to get vote event")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
if !voteEvent.ResultsOpen {
logger.FromContext(c).Info("VoteEventHandler::GetVoteResults -> Results not open for viewing")
h.sendErrorResponse(c, "Results are not open for viewing", http.StatusForbidden)
return
}
results, err := h.voteEventService.GetVoteResults(c.Request.Context(), eventID)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::GetVoteResults -> Failed to get vote results")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
logger.FromContext(c).Infof("VoteEventHandler::GetVoteResults -> Successfully retrieved vote results")
c.JSON(http.StatusOK, contract.BuildSuccessResponse(results))
}
func (h *VoteEventHandler) CheckVoteStatus(c *gin.Context) {
appCtx := appcontext.FromGinContext(c.Request.Context())
if appCtx.UserID == uuid.Nil {
h.sendErrorResponse(c, "Unauthorized", http.StatusUnauthorized)
return
}
eventIDStr := c.Param("id")
eventID, err := uuid.Parse(eventIDStr)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::CheckVoteStatus -> Invalid event ID")
h.sendValidationErrorResponse(c, "Invalid event ID", constants.MalformedFieldErrorCode)
return
}
status, err := h.voteEventService.CheckVoteStatus(c.Request.Context(), appCtx.UserID, eventID)
if err != nil {
logger.FromContext(c).WithError(err).Error("VoteEventHandler::CheckVoteStatus -> Failed to check vote status")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
logger.FromContext(c).Infof("VoteEventHandler::CheckVoteStatus -> Successfully checked vote status")
c.JSON(http.StatusOK, contract.BuildSuccessResponse(status))
}
func (h *VoteEventHandler) sendErrorResponse(c *gin.Context, message string, statusCode int) {
errorResponse := &contract.ErrorResponse{
Error: message,
Code: statusCode,
Details: map[string]interface{}{},
}
c.JSON(statusCode, errorResponse)
}
func (h *VoteEventHandler) sendValidationErrorResponse(c *gin.Context, message string, errorCode string) {
statusCode := constants.HttpErrorMap[errorCode]
if statusCode == 0 {
statusCode = http.StatusBadRequest
}
errorResponse := &contract.ErrorResponse{
Error: message,
Code: statusCode,
Details: map[string]interface{}{
"error_code": errorCode,
"entity": "vote_event",
},
}
c.JSON(statusCode, errorResponse)
}

View File

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

View File

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

View File

@ -5,11 +5,13 @@ import (
)
func CORS() gin.HandlerFunc {
return gin.HandlerFunc(func(c *gin.Context) {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-Correlation-ID")
c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
c.Header("Access-Control-Expose-Headers", "X-Correlation-ID")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
@ -17,5 +19,5 @@ func CORS() gin.HandlerFunc {
}
c.Next()
})
}
}

View File

@ -15,25 +15,26 @@ import (
)
type LetterProcessorImpl struct {
letterRepo *repository.LetterIncomingRepository
attachRepo *repository.LetterIncomingAttachmentRepository
txManager *repository.TxManager
activity *ActivityLogProcessorImpl
// new repos for dispositions
dispositionRepo *repository.LetterDispositionRepository
letterRepo *repository.LetterIncomingRepository
attachRepo *repository.LetterIncomingAttachmentRepository
txManager *repository.TxManager
activity *ActivityLogProcessorImpl
dispositionRepo *repository.LetterIncomingDispositionRepository
dispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository
dispositionActionSelRepo *repository.LetterDispositionActionSelectionRepository
dispositionNoteRepo *repository.DispositionNoteRepository
// discussion repo
discussionRepo *repository.LetterDiscussionRepository
// settings and recipients
settingRepo *repository.AppSettingRepository
recipientRepo *repository.LetterIncomingRecipientRepository
departmentRepo *repository.DepartmentRepository
userDeptRepo *repository.UserDepartmentRepository
discussionRepo *repository.LetterDiscussionRepository
settingRepo *repository.AppSettingRepository
recipientRepo *repository.LetterIncomingRecipientRepository
departmentRepo *repository.DepartmentRepository
userDeptRepo *repository.UserDepartmentRepository
priorityRepo *repository.PriorityRepository
institutionRepo *repository.InstitutionRepository
dispActionRepo *repository.DispositionActionRepository
}
func NewLetterProcessor(letterRepo *repository.LetterIncomingRepository, attachRepo *repository.LetterIncomingAttachmentRepository, txManager *repository.TxManager, activity *ActivityLogProcessorImpl, dispRepo *repository.LetterDispositionRepository, dispSelRepo *repository.LetterDispositionActionSelectionRepository, noteRepo *repository.DispositionNoteRepository, discussionRepo *repository.LetterDiscussionRepository, settingRepo *repository.AppSettingRepository, recipientRepo *repository.LetterIncomingRecipientRepository, departmentRepo *repository.DepartmentRepository, userDeptRepo *repository.UserDepartmentRepository) *LetterProcessorImpl {
return &LetterProcessorImpl{letterRepo: letterRepo, attachRepo: attachRepo, txManager: txManager, activity: activity, dispositionRepo: dispRepo, dispositionActionSelRepo: dispSelRepo, dispositionNoteRepo: noteRepo, discussionRepo: discussionRepo, settingRepo: settingRepo, recipientRepo: recipientRepo, departmentRepo: departmentRepo, userDeptRepo: userDeptRepo}
func NewLetterProcessor(letterRepo *repository.LetterIncomingRepository, attachRepo *repository.LetterIncomingAttachmentRepository, txManager *repository.TxManager, activity *ActivityLogProcessorImpl, dispRepo *repository.LetterIncomingDispositionRepository, dispDeptRepo *repository.LetterIncomingDispositionDepartmentRepository, dispSelRepo *repository.LetterDispositionActionSelectionRepository, noteRepo *repository.DispositionNoteRepository, discussionRepo *repository.LetterDiscussionRepository, settingRepo *repository.AppSettingRepository, recipientRepo *repository.LetterIncomingRecipientRepository, departmentRepo *repository.DepartmentRepository, userDeptRepo *repository.UserDepartmentRepository, priorityRepo *repository.PriorityRepository, institutionRepo *repository.InstitutionRepository, dispActionRepo *repository.DispositionActionRepository) *LetterProcessorImpl {
return &LetterProcessorImpl{letterRepo: letterRepo, attachRepo: attachRepo, txManager: txManager, activity: activity, dispositionRepo: dispRepo, dispositionDeptRepo: dispDeptRepo, dispositionActionSelRepo: dispSelRepo, dispositionNoteRepo: noteRepo, discussionRepo: discussionRepo, settingRepo: settingRepo, recipientRepo: recipientRepo, departmentRepo: departmentRepo, userDeptRepo: userDeptRepo, priorityRepo: priorityRepo, institutionRepo: institutionRepo, dispActionRepo: dispActionRepo}
}
func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) {
@ -85,7 +86,6 @@ func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *con
}
}
// resolve department codes to ids using repository
depIDs := make([]uuid.UUID, 0, len(defaultDeptCodes))
for _, code := range defaultDeptCodes {
dep, err := p.departmentRepo.GetByCode(txCtx, code)
@ -94,20 +94,19 @@ func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *con
}
depIDs = append(depIDs, dep.ID)
}
// query user memberships for all departments at once
userMemberships, _ := p.userDeptRepo.ListActiveByDepartmentIDs(txCtx, depIDs)
// build recipients: one department recipient per department + one user recipient per membership
recipients := make([]entities.LetterIncomingRecipient, 0, len(depIDs)+len(userMemberships))
// department recipients
for _, depID := range depIDs {
id := depID
recipients = append(recipients, entities.LetterIncomingRecipient{LetterID: entity.ID, RecipientDepartmentID: &id, Status: entities.RecipientStatusNew})
}
// user recipients
var recipients []entities.LetterIncomingRecipient
mapsUsers := map[string]bool{}
for _, row := range userMemberships {
uid := row.UserID
recipients = append(recipients, entities.LetterIncomingRecipient{LetterID: entity.ID, RecipientUserID: &uid, Status: entities.RecipientStatusNew})
if _, ok := mapsUsers[uid.String()]; !ok {
recipients = append(recipients, entities.LetterIncomingRecipient{LetterID: entity.ID, RecipientUserID: &uid, RecipientDepartmentID: &row.DepartmentID, Status: entities.RecipientStatusNew})
}
mapsUsers[uid.String()] = true
}
if len(recipients) > 0 {
if err := p.recipientRepo.CreateBulk(txCtx, recipients); err != nil {
return err
@ -141,9 +140,26 @@ func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *con
}
savedAttachments, _ := p.attachRepo.ListByLetter(txCtx, entity.ID)
result = transformer.LetterEntityToContract(entity, savedAttachments)
var pr *entities.Priority
if entity.PriorityID != nil {
if p.priorityRepo != nil {
if got, err := p.priorityRepo.Get(txCtx, *entity.PriorityID); err == nil {
pr = got
}
}
}
var inst *entities.Institution
if entity.SenderInstitutionID != nil {
if p.institutionRepo != nil {
if got, err := p.institutionRepo.Get(txCtx, *entity.SenderInstitutionID); err == nil {
inst = got
}
}
}
result = transformer.LetterEntityToContract(entity, savedAttachments, pr, inst)
return nil
})
if err != nil {
return nil, err
}
@ -156,7 +172,19 @@ func (p *LetterProcessorImpl) GetIncomingLetterByID(ctx context.Context, id uuid
return nil, err
}
atts, _ := p.attachRepo.ListByLetter(ctx, id)
return transformer.LetterEntityToContract(entity, atts), nil
var pr *entities.Priority
if entity.PriorityID != nil && p.priorityRepo != nil {
if got, err := p.priorityRepo.Get(ctx, *entity.PriorityID); err == nil {
pr = got
}
}
var inst *entities.Institution
if entity.SenderInstitutionID != nil && p.institutionRepo != nil {
if got, err := p.institutionRepo.Get(ctx, *entity.SenderInstitutionID); err == nil {
inst = got
}
}
return transformer.LetterEntityToContract(entity, atts, pr, inst), nil
}
func (p *LetterProcessorImpl) ListIncomingLetters(ctx context.Context, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error) {
@ -175,7 +203,19 @@ func (p *LetterProcessorImpl) ListIncomingLetters(ctx context.Context, req *cont
respList := make([]contract.IncomingLetterResponse, 0, len(list))
for _, e := range list {
atts, _ := p.attachRepo.ListByLetter(ctx, e.ID)
resp := transformer.LetterEntityToContract(&e, atts)
var pr *entities.Priority
if e.PriorityID != nil && p.priorityRepo != nil {
if got, err := p.priorityRepo.Get(ctx, *e.PriorityID); err == nil {
pr = got
}
}
var inst *entities.Institution
if e.SenderInstitutionID != nil && p.institutionRepo != nil {
if got, err := p.institutionRepo.Get(ctx, *e.SenderInstitutionID); err == nil {
inst = got
}
}
resp := transformer.LetterEntityToContract(&e, atts, pr, inst)
respList = append(respList, *resp)
}
return &contract.ListIncomingLettersResponse{Letters: respList, Pagination: transformer.CreatePaginationResponse(int(total), page, limit)}, nil
@ -225,7 +265,19 @@ func (p *LetterProcessorImpl) UpdateIncomingLetter(ctx context.Context, id uuid.
}
}
atts, _ := p.attachRepo.ListByLetter(txCtx, id)
out = transformer.LetterEntityToContract(entity, atts)
var pr *entities.Priority
if entity.PriorityID != nil && p.priorityRepo != nil {
if got, err := p.priorityRepo.Get(txCtx, *entity.PriorityID); err == nil {
pr = got
}
}
var inst *entities.Institution
if entity.SenderInstitutionID != nil && p.institutionRepo != nil {
if got, err := p.institutionRepo.Get(txCtx, *entity.SenderInstitutionID); err == nil {
inst = got
}
}
out = transformer.LetterEntityToContract(entity, atts, pr, inst)
return nil
})
if err != nil {
@ -254,48 +306,53 @@ func (p *LetterProcessorImpl) CreateDispositions(ctx context.Context, req *contr
var out *contract.ListDispositionsResponse
err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
userID := appcontext.FromGinContext(txCtx).UserID
created := make([]entities.LetterDisposition, 0, len(req.ToDepartmentIDs))
disp := entities.LetterIncomingDisposition{
LetterID: req.LetterID,
DepartmentID: &req.FromDepartment,
Notes: req.Notes,
CreatedBy: userID,
}
if err := p.dispositionRepo.Create(txCtx, &disp); err != nil {
return err
}
var dispDepartments []entities.LetterIncomingDispositionDepartment
for _, toDept := range req.ToDepartmentIDs {
disp := entities.LetterDisposition{
LetterID: req.LetterID,
FromDepartmentID: nil,
ToDepartmentID: &toDept,
Notes: req.Notes,
Status: entities.DispositionPending,
CreatedBy: userID,
dispDepartments = append(dispDepartments, entities.LetterIncomingDispositionDepartment{
LetterIncomingDispositionID: disp.ID,
DepartmentID: toDept,
})
}
if err := p.dispositionDeptRepo.CreateBulk(txCtx, dispDepartments); err != nil {
return err
}
if len(req.SelectedActions) > 0 {
selections := make([]entities.LetterDispositionActionSelection, 0, len(req.SelectedActions))
for _, sel := range req.SelectedActions {
selections = append(selections, entities.LetterDispositionActionSelection{
DispositionID: disp.ID,
ActionID: sel.ActionID,
Note: sel.Note,
CreatedBy: userID,
})
}
if err := p.dispositionRepo.Create(txCtx, &disp); err != nil {
if err := p.dispositionActionSelRepo.CreateBulk(txCtx, selections); err != nil {
return err
}
created = append(created, disp)
if len(req.SelectedActions) > 0 {
selections := make([]entities.LetterDispositionActionSelection, 0, len(req.SelectedActions))
for _, sel := range req.SelectedActions {
selections = append(selections, entities.LetterDispositionActionSelection{
DispositionID: disp.ID,
ActionID: sel.ActionID,
Note: sel.Note,
CreatedBy: userID,
})
}
if err := p.dispositionActionSelRepo.CreateBulk(txCtx, selections); err != nil {
return err
}
}
if p.activity != nil {
action := "disposition.created"
for _, d := range created {
ctxMap := map[string]interface{}{"to_department_id": d.ToDepartmentID}
if err := p.activity.Log(txCtx, req.LetterID, action, &userID, nil, nil, &d.ID, nil, nil, ctxMap); err != nil {
return err
}
}
}
}
out = &contract.ListDispositionsResponse{Dispositions: transformer.DispositionsToContract(created)}
if p.activity != nil {
action := "disposition.created"
ctxMap := map[string]interface{}{"to_department_id": dispDepartments}
if err := p.activity.Log(txCtx, req.LetterID, action, &userID, nil, nil, &disp.ID, nil, nil, ctxMap); err != nil {
return err
}
}
out = &contract.ListDispositionsResponse{Dispositions: []contract.DispositionResponse{transformer.DispoToContract(disp)}}
return nil
})
if err != nil {
@ -312,6 +369,64 @@ func (p *LetterProcessorImpl) ListDispositionsByLetter(ctx context.Context, lett
return &contract.ListDispositionsResponse{Dispositions: transformer.DispositionsToContract(list)}, nil
}
func (p *LetterProcessorImpl) GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) {
// Get dispositions with all related data preloaded in a single query
dispositions, err := p.dispositionRepo.ListByLetter(ctx, letterID)
if err != nil {
return nil, err
}
// Get discussions with preloaded user profiles
discussions, err := p.discussionRepo.ListByLetter(ctx, letterID)
if err != nil {
return nil, err
}
// Extract all mentioned user IDs from discussions for efficient batch fetching
var mentionedUserIDs []uuid.UUID
mentionedUserIDsMap := make(map[uuid.UUID]bool)
for _, discussion := range discussions {
if discussion.Mentions != nil {
mentions := map[string]interface{}(discussion.Mentions)
if userIDs, ok := mentions["user_ids"]; ok {
if userIDList, ok := userIDs.([]interface{}); ok {
for _, userID := range userIDList {
if userIDStr, ok := userID.(string); ok {
if userUUID, err := uuid.Parse(userIDStr); err == nil {
if !mentionedUserIDsMap[userUUID] {
mentionedUserIDsMap[userUUID] = true
mentionedUserIDs = append(mentionedUserIDs, userUUID)
}
}
}
}
}
}
}
}
// Fetch all mentioned users in a single batch query
var mentionedUsers []entities.User
if len(mentionedUserIDs) > 0 {
mentionedUsers, err = p.discussionRepo.GetUsersByIDs(ctx, mentionedUserIDs)
if err != nil {
return nil, err
}
}
// Transform dispositions
enhancedDispositions := transformer.EnhancedDispositionsWithPreloadedDataToContract(dispositions)
// Transform discussions with mentioned users
enhancedDiscussions := transformer.DiscussionsWithPreloadedDataToContract(discussions, mentionedUsers)
return &contract.ListEnhancedDispositionsResponse{
Dispositions: enhancedDispositions,
Discussions: enhancedDiscussions,
}, nil
}
func (p *LetterProcessorImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) {
var out *contract.LetterDiscussionResponse
err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
@ -320,7 +435,7 @@ func (p *LetterProcessorImpl) CreateDiscussion(ctx context.Context, letterID uui
if req.Mentions != nil {
mentions = entities.JSONB(req.Mentions)
}
disc := &entities.LetterDiscussion{LetterID: letterID, ParentID: req.ParentID, UserID: userID, Message: req.Message, Mentions: mentions}
disc := &entities.LetterDiscussion{ID: uuid.New(), LetterID: letterID, ParentID: req.ParentID, UserID: userID, Message: req.Message, Mentions: mentions}
if err := p.discussionRepo.Create(txCtx, disc); err != nil {
return err
}

View File

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

View File

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

View File

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

View File

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

View File

@ -113,6 +113,17 @@ func (r *DispositionActionRepository) Get(ctx context.Context, id uuid.UUID) (*e
return &e, nil
}
func (r *DispositionActionRepository) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]entities.DispositionAction, error) {
var actions []entities.DispositionAction
if len(ids) == 0 {
return actions, nil
}
if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&actions).Error; err != nil {
return nil, err
}
return actions, nil
}
type DepartmentRepository struct{ db *gorm.DB }
func NewDepartmentRepository(db *gorm.DB) *DepartmentRepository { return &DepartmentRepository{db: db} }
@ -125,3 +136,12 @@ func (r *DepartmentRepository) GetByCode(ctx context.Context, code string) (*ent
}
return &dep, nil
}
func (r *DepartmentRepository) Get(ctx context.Context, id uuid.UUID) (*entities.Department, error) {
db := DBFromContext(ctx, r.db)
var dep entities.Department
if err := db.WithContext(ctx).First(&dep, "id = ?", id).Error; err != nil {
return nil, err
}
return &dep, nil
}

View File

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

View File

@ -0,0 +1,122 @@
package repository
import (
"context"
"time"
"eslogad-be/internal/entities"
"github.com/google/uuid"
"gorm.io/gorm"
)
type VoteEventRepositoryImpl struct {
db *gorm.DB
}
func NewVoteEventRepository(db *gorm.DB) *VoteEventRepositoryImpl {
return &VoteEventRepositoryImpl{
db: db,
}
}
func (r *VoteEventRepositoryImpl) Create(ctx context.Context, voteEvent *entities.VoteEvent) error {
return r.db.WithContext(ctx).Create(voteEvent).Error
}
func (r *VoteEventRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.VoteEvent, error) {
var voteEvent entities.VoteEvent
err := r.db.WithContext(ctx).
Preload("Candidates").
First(&voteEvent, "id = ?", id).Error
if err != nil {
return nil, err
}
return &voteEvent, nil
}
func (r *VoteEventRepositoryImpl) GetActiveEvents(ctx context.Context) ([]*entities.VoteEvent, error) {
var events []*entities.VoteEvent
now := time.Now()
err := r.db.WithContext(ctx).
Preload("Candidates").
Where("is_active = ? AND start_date <= ? AND end_date >= ?", true, now, now).
Find(&events).Error
return events, err
}
func (r *VoteEventRepositoryImpl) List(ctx context.Context, limit, offset int) ([]*entities.VoteEvent, int64, error) {
var events []*entities.VoteEvent
var total int64
if err := r.db.WithContext(ctx).Model(&entities.VoteEvent{}).Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.WithContext(ctx).
Preload("Candidates").
Limit(limit).
Offset(offset).
Order("created_at DESC").
Find(&events).Error
return events, total, err
}
func (r *VoteEventRepositoryImpl) Update(ctx context.Context, voteEvent *entities.VoteEvent) error {
return r.db.WithContext(ctx).Save(voteEvent).Error
}
func (r *VoteEventRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.VoteEvent{}, "id = ?", id).Error
}
func (r *VoteEventRepositoryImpl) CreateCandidate(ctx context.Context, candidate *entities.Candidate) error {
return r.db.WithContext(ctx).Create(candidate).Error
}
func (r *VoteEventRepositoryImpl) GetCandidatesByEventID(ctx context.Context, eventID uuid.UUID) ([]*entities.Candidate, error) {
var candidates []*entities.Candidate
err := r.db.WithContext(ctx).
Where("vote_event_id = ?", eventID).
Find(&candidates).Error
return candidates, err
}
func (r *VoteEventRepositoryImpl) SubmitVote(ctx context.Context, vote *entities.Vote) error {
return r.db.WithContext(ctx).Create(vote).Error
}
func (r *VoteEventRepositoryImpl) HasUserVoted(ctx context.Context, userID, eventID uuid.UUID) (bool, error) {
var count int64
err := r.db.WithContext(ctx).
Model(&entities.Vote{}).
Where("user_id = ? AND vote_event_id = ?", userID, eventID).
Count(&count).Error
return count > 0, err
}
func (r *VoteEventRepositoryImpl) GetVoteResults(ctx context.Context, eventID uuid.UUID) (map[uuid.UUID]int64, error) {
type result struct {
CandidateID uuid.UUID
VoteCount int64
}
var results []result
err := r.db.WithContext(ctx).
Model(&entities.Vote{}).
Select("candidate_id, COUNT(*) as vote_count").
Where("vote_event_id = ?", eventID).
Group("candidate_id").
Scan(&results).Error
if err != nil {
return nil, err
}
resultMap := make(map[uuid.UUID]int64)
for _, r := range results {
resultMap[r.CandidateID] = r.VoteCount
}
return resultMap, nil
}

View File

@ -12,6 +12,8 @@ type UserHandler interface {
UpdateProfile(c *gin.Context)
ChangePassword(c *gin.Context)
ListTitles(c *gin.Context)
GetActiveUsersForMention(c *gin.Context)
BulkCreateUsers(c *gin.Context)
}
type FileHandler interface {
@ -62,7 +64,8 @@ type LetterHandler interface {
DeleteIncomingLetter(c *gin.Context)
CreateDispositions(c *gin.Context)
ListDispositionsByLetter(c *gin.Context)
//ListDispositionsByLetter(c *gin.Context)
GetEnhancedDispositionsByLetter(c *gin.Context)
CreateDiscussion(c *gin.Context)
UpdateDiscussion(c *gin.Context)
@ -75,3 +78,17 @@ type DispositionRouteHandler interface {
ListByFromDept(c *gin.Context)
SetActive(c *gin.Context)
}
type VoteEventHandler interface {
CreateVoteEvent(c *gin.Context)
GetVoteEvent(c *gin.Context)
GetActiveEvents(c *gin.Context)
ListVoteEvents(c *gin.Context)
UpdateVoteEvent(c *gin.Context)
DeleteVoteEvent(c *gin.Context)
CreateCandidate(c *gin.Context)
SubmitVote(c *gin.Context)
GetVoteResults(c *gin.Context)
CheckVoteStatus(c *gin.Context)
GetCandidates(c *gin.Context)
}

View File

@ -18,6 +18,7 @@ type Router struct {
masterHandler MasterHandler
letterHandler LetterHandler
dispRouteHandler DispositionRouteHandler
voteEventHandler VoteEventHandler
}
func NewRouter(
@ -31,6 +32,7 @@ func NewRouter(
masterHandler MasterHandler,
letterHandler LetterHandler,
dispRouteHandler DispositionRouteHandler,
voteEventHandler VoteEventHandler,
) *Router {
return &Router{
config: cfg,
@ -43,6 +45,7 @@ func NewRouter(
masterHandler: masterHandler,
letterHandler: letterHandler,
dispRouteHandler: dispRouteHandler,
voteEventHandler: voteEventHandler,
}
}
@ -50,12 +53,12 @@ func (r *Router) Init() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
engine.Use(
middleware.CORS(),
middleware.JsonAPI(),
middleware.CorrelationID(),
middleware.Recover(),
middleware.HTTPStatLogger(),
middleware.PopulateContext(),
middleware.CORS(),
)
r.addAppRoutes(engine)
@ -78,10 +81,12 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
users.Use(r.authMiddleware.RequireAuth())
{
users.GET("", r.authMiddleware.RequirePermissions("user.read"), r.userHandler.ListUsers)
users.POST("/bulk", r.userHandler.BulkCreateUsers)
users.GET("/profile", r.userHandler.GetProfile)
users.PUT("/profile", r.userHandler.UpdateProfile)
users.PUT(":id/password", r.userHandler.ChangePassword)
users.GET("/titles", r.userHandler.ListTitles)
users.GET("/mention", r.userHandler.GetActiveUsersForMention)
users.POST("/profile/avatar", r.fileHandler.UploadProfileAvatar)
}
@ -139,7 +144,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
lettersch.DELETE("/incoming/:id", r.letterHandler.DeleteIncomingLetter)
lettersch.POST("/dispositions/:letter_id", r.letterHandler.CreateDispositions)
lettersch.GET("/dispositions/:letter_id", r.letterHandler.ListDispositionsByLetter)
lettersch.GET("/dispositions/:letter_id", r.letterHandler.GetEnhancedDispositionsByLetter)
lettersch.POST("/discussions/:letter_id", r.letterHandler.CreateDiscussion)
lettersch.PUT("/discussions/:letter_id/:discussion_id", r.letterHandler.UpdateDiscussion)
@ -151,8 +156,34 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
droutes.POST("", r.dispRouteHandler.Create)
droutes.GET(":id", r.dispRouteHandler.Get)
droutes.PUT(":id", r.dispRouteHandler.Update)
droutes.GET("from/:from_department_id", r.dispRouteHandler.ListByFromDept)
droutes.GET("department", r.dispRouteHandler.ListByFromDept)
droutes.PUT(":id/active", r.dispRouteHandler.SetActive)
}
voteEvents := v1.Group("/vote-events")
voteEvents.Use(r.authMiddleware.RequireAuth())
{
voteEvents.POST("", r.voteEventHandler.CreateVoteEvent)
voteEvents.GET("", r.voteEventHandler.ListVoteEvents)
voteEvents.GET("/active", r.voteEventHandler.GetActiveEvents)
voteEvents.GET("/:id", r.voteEventHandler.GetVoteEvent)
voteEvents.PUT("/:id", r.voteEventHandler.UpdateVoteEvent)
voteEvents.DELETE("/:id", r.voteEventHandler.DeleteVoteEvent)
voteEvents.GET("/:id/candidates", r.voteEventHandler.GetCandidates)
voteEvents.GET("/:id/results", r.voteEventHandler.GetVoteResults)
voteEvents.GET("/:id/vote-status", r.voteEventHandler.CheckVoteStatus)
}
candidates := v1.Group("/candidates")
candidates.Use(r.authMiddleware.RequireAuth())
{
candidates.POST("", r.voteEventHandler.CreateCandidate)
}
votes := v1.Group("/votes")
votes.Use(r.authMiddleware.RequireAuth())
{
votes.POST("", r.voteEventHandler.SubmitVote)
}
}
}

View File

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

View File

@ -16,7 +16,7 @@ type LetterProcessor interface {
SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error
CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error)
ListDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListDispositionsResponse, error)
GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error)
CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error)
UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error)
@ -50,8 +50,8 @@ func (s *LetterServiceImpl) CreateDispositions(ctx context.Context, req *contrac
return s.processor.CreateDispositions(ctx, req)
}
func (s *LetterServiceImpl) ListDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListDispositionsResponse, error) {
return s.processor.ListDispositionsByLetter(ctx, letterID)
func (s *LetterServiceImpl) GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) {
return s.processor.GetEnhancedDispositionsByLetter(ctx, letterID)
}
func (s *LetterServiceImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) {

View File

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

View File

@ -30,6 +30,40 @@ func (s *UserServiceImpl) CreateUser(ctx context.Context, req *contract.CreateUs
return s.userProcessor.CreateUser(ctx, req)
}
func (s *UserServiceImpl) BulkCreateUsers(ctx context.Context, req *contract.BulkCreateUsersRequest) (*contract.BulkCreateUsersResponse, error) {
response := &contract.BulkCreateUsersResponse{
Created: []contract.UserResponse{},
Failed: []contract.BulkUserErrorResult{},
Summary: contract.BulkCreationSummary{
Total: len(req.Users),
Succeeded: 0,
Failed: 0,
},
}
for _, userReq := range req.Users {
createReq := &contract.CreateUserRequest{
Name: userReq.Name,
Email: userReq.Email,
Password: userReq.Password,
}
userResponse, err := s.userProcessor.CreateUser(ctx, createReq)
if err != nil {
response.Failed = append(response.Failed, contract.BulkUserErrorResult{
User: userReq,
Error: err.Error(),
})
response.Summary.Failed++
} else {
response.Created = append(response.Created, *userResponse)
response.Summary.Succeeded++
}
}
return response, nil
}
func (s *UserServiceImpl) UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) {
return s.userProcessor.UpdateUser(ctx, id, req)
}
@ -96,3 +130,8 @@ func (s *UserServiceImpl) ListTitles(ctx context.Context) (*contract.ListTitlesR
}
return &contract.ListTitlesResponse{Titles: transformer.TitlesToContract(titles)}, nil
}
// GetActiveUsersForMention retrieves active users for mention purposes
func (s *UserServiceImpl) GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) {
return s.userProcessor.GetActiveUsersForMention(ctx, search, limit)
}

View File

@ -0,0 +1,258 @@
package service
import (
"context"
"errors"
"eslogad-be/internal/contract"
"eslogad-be/internal/entities"
"eslogad-be/internal/transformer"
"github.com/google/uuid"
)
type VoteEventRepository interface {
Create(ctx context.Context, voteEvent *entities.VoteEvent) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.VoteEvent, error)
GetActiveEvents(ctx context.Context) ([]*entities.VoteEvent, error)
List(ctx context.Context, limit, offset int) ([]*entities.VoteEvent, int64, error)
Update(ctx context.Context, voteEvent *entities.VoteEvent) error
Delete(ctx context.Context, id uuid.UUID) error
CreateCandidate(ctx context.Context, candidate *entities.Candidate) error
GetCandidatesByEventID(ctx context.Context, eventID uuid.UUID) ([]*entities.Candidate, error)
SubmitVote(ctx context.Context, vote *entities.Vote) error
HasUserVoted(ctx context.Context, userID, eventID uuid.UUID) (bool, error)
GetVoteResults(ctx context.Context, eventID uuid.UUID) (map[uuid.UUID]int64, error)
}
type VoteEventServiceImpl struct {
voteEventRepo VoteEventRepository
}
func NewVoteEventService(voteEventRepo VoteEventRepository) *VoteEventServiceImpl {
return &VoteEventServiceImpl{
voteEventRepo: voteEventRepo,
}
}
func (s *VoteEventServiceImpl) CreateVoteEvent(ctx context.Context, req *contract.CreateVoteEventRequest) (*contract.VoteEventResponse, error) {
if req.EndDate.Before(req.StartDate) {
return nil, errors.New("end date must be after start date")
}
voteEvent := &entities.VoteEvent{
Title: req.Title,
Description: req.Description,
StartDate: req.StartDate,
EndDate: req.EndDate,
IsActive: true,
ResultsOpen: false,
}
if req.ResultsOpen != nil {
voteEvent.ResultsOpen = *req.ResultsOpen
}
if err := s.voteEventRepo.Create(ctx, voteEvent); err != nil {
return nil, err
}
return transformer.VoteEventToContract(voteEvent), nil
}
func (s *VoteEventServiceImpl) GetVoteEventByID(ctx context.Context, id uuid.UUID) (*contract.VoteEventResponse, error) {
voteEvent, err := s.voteEventRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
return transformer.VoteEventToContract(voteEvent), nil
}
func (s *VoteEventServiceImpl) GetActiveEvents(ctx context.Context) ([]contract.VoteEventResponse, error) {
events, err := s.voteEventRepo.GetActiveEvents(ctx)
if err != nil {
return nil, err
}
var responses []contract.VoteEventResponse
for _, event := range events {
responses = append(responses, *transformer.VoteEventToContract(event))
}
return responses, nil
}
func (s *VoteEventServiceImpl) ListVoteEvents(ctx context.Context, req *contract.ListVoteEventsRequest) (*contract.ListVoteEventsResponse, error) {
page := req.Page
if page <= 0 {
page = 1
}
limit := req.Limit
if limit <= 0 {
limit = 10
}
offset := (page - 1) * limit
events, total, err := s.voteEventRepo.List(ctx, limit, offset)
if err != nil {
return nil, err
}
var responses []contract.VoteEventResponse
for _, event := range events {
responses = append(responses, *transformer.VoteEventToContract(event))
}
return &contract.ListVoteEventsResponse{
VoteEvents: responses,
Pagination: transformer.CreatePaginationResponse(int(total), page, limit),
}, nil
}
func (s *VoteEventServiceImpl) UpdateVoteEvent(ctx context.Context, id uuid.UUID, req *contract.UpdateVoteEventRequest) (*contract.VoteEventResponse, error) {
if req.EndDate.Before(req.StartDate) {
return nil, errors.New("end date must be after start date")
}
voteEvent, err := s.voteEventRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
voteEvent.Title = req.Title
voteEvent.Description = req.Description
voteEvent.StartDate = req.StartDate
voteEvent.EndDate = req.EndDate
voteEvent.IsActive = req.IsActive
if req.ResultsOpen != nil {
voteEvent.ResultsOpen = *req.ResultsOpen
}
if err := s.voteEventRepo.Update(ctx, voteEvent); err != nil {
return nil, err
}
return transformer.VoteEventToContract(voteEvent), nil
}
func (s *VoteEventServiceImpl) DeleteVoteEvent(ctx context.Context, id uuid.UUID) error {
return s.voteEventRepo.Delete(ctx, id)
}
func (s *VoteEventServiceImpl) CreateCandidate(ctx context.Context, req *contract.CreateCandidateRequest) (*contract.CandidateResponse, error) {
voteEvent, err := s.voteEventRepo.GetByID(ctx, req.VoteEventID)
if err != nil {
return nil, err
}
if !voteEvent.IsActive {
return nil, errors.New("cannot add candidates to inactive vote event")
}
candidate := &entities.Candidate{
VoteEventID: req.VoteEventID,
Name: req.Name,
ImageURL: req.ImageURL,
Description: req.Description,
}
if err := s.voteEventRepo.CreateCandidate(ctx, candidate); err != nil {
return nil, err
}
return transformer.CandidateToContract(candidate), nil
}
func (s *VoteEventServiceImpl) GetCandidates(ctx context.Context, eventID uuid.UUID) ([]contract.CandidateResponse, error) {
candidates, err := s.voteEventRepo.GetCandidatesByEventID(ctx, eventID)
if err != nil {
return nil, err
}
var responses []contract.CandidateResponse
for _, candidate := range candidates {
responses = append(responses, *transformer.CandidateToContract(candidate))
}
return responses, nil
}
func (s *VoteEventServiceImpl) SubmitVote(ctx context.Context, userID uuid.UUID, req *contract.SubmitVoteRequest) (*contract.VoteResponse, error) {
voteEvent, err := s.voteEventRepo.GetByID(ctx, req.VoteEventID)
if err != nil {
return nil, err
}
if !voteEvent.IsVotingOpen() {
return nil, errors.New("voting is not open for this event")
}
hasVoted, err := s.voteEventRepo.HasUserVoted(ctx, userID, req.VoteEventID)
if err != nil {
return nil, err
}
if hasVoted {
return nil, errors.New("user has already voted for this event")
}
vote := &entities.Vote{
VoteEventID: req.VoteEventID,
CandidateID: req.CandidateID,
UserID: userID,
}
if err := s.voteEventRepo.SubmitVote(ctx, vote); err != nil {
return nil, err
}
return transformer.VoteToContract(vote), nil
}
func (s *VoteEventServiceImpl) GetVoteResults(ctx context.Context, eventID uuid.UUID) (*contract.VoteResultsResponse, error) {
candidates, err := s.voteEventRepo.GetCandidatesByEventID(ctx, eventID)
if err != nil {
return nil, err
}
voteResults, err := s.voteEventRepo.GetVoteResults(ctx, eventID)
if err != nil {
return nil, err
}
var candidatesWithVotes []contract.CandidateWithVotesResponse
var totalVotes int64
for _, candidate := range candidates {
voteCount := voteResults[candidate.ID]
totalVotes += voteCount
candidatesWithVotes = append(candidatesWithVotes, contract.CandidateWithVotesResponse{
CandidateResponse: *transformer.CandidateToContract(candidate),
VoteCount: voteCount,
})
}
return &contract.VoteResultsResponse{
VoteEventID: eventID,
Candidates: candidatesWithVotes,
TotalVotes: totalVotes,
}, nil
}
func (s *VoteEventServiceImpl) CheckVoteStatus(ctx context.Context, userID, eventID uuid.UUID) (*contract.CheckVoteStatusResponse, error) {
hasVoted, err := s.voteEventRepo.HasUserVoted(ctx, userID, eventID)
if err != nil {
return nil, err
}
response := &contract.CheckVoteStatusResponse{
HasVoted: hasVoted,
}
return response, nil
}

View File

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

View File

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

View File

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

View File

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

@ -1,7 +0,0 @@
BEGIN;
DROP TABLE IF EXISTS institutions;
DROP TABLE IF EXISTS priorities;
DROP TABLE IF EXISTS labels;
COMMIT;

View File

@ -1,52 +0,0 @@
BEGIN;
-- =======================
-- LABELS
-- =======================
CREATE TABLE IF NOT EXISTS labels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
color VARCHAR(16), -- HEX color code (e.g., #FF0000)
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TRIGGER trg_labels_updated_at
BEFORE UPDATE ON labels
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =======================
-- PRIORITIES
-- =======================
CREATE TABLE IF NOT EXISTS priorities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
level INT NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TRIGGER trg_priorities_updated_at
BEFORE UPDATE ON priorities
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =======================
-- INSTITUTIONS
-- =======================
CREATE TABLE IF NOT EXISTS institutions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
type TEXT NOT NULL CHECK (type IN ('government','private','ngo','individual')),
address TEXT,
contact_person VARCHAR(255),
phone VARCHAR(50),
email VARCHAR(255),
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TRIGGER trg_institutions_updated_at
BEFORE UPDATE ON institutions
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
COMMIT;

View File

@ -1,5 +0,0 @@
BEGIN;
DROP TABLE IF EXISTS disposition_actions;
COMMIT;

View File

@ -1,23 +0,0 @@
BEGIN;
-- =======================
-- DISPOSITION ACTIONS
-- =======================
CREATE TABLE IF NOT EXISTS disposition_actions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
description TEXT,
requires_note BOOLEAN NOT NULL DEFAULT FALSE,
group_name TEXT,
sort_order INT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TRIGGER trg_disposition_actions_updated_at
BEFORE UPDATE ON disposition_actions
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
COMMIT;

View File

@ -1,16 +0,0 @@
BEGIN;
DROP TABLE IF EXISTS letter_incoming_activity_logs;
DROP TABLE IF EXISTS letter_incoming_discussion_attachments;
DROP TABLE IF EXISTS letter_incoming_discussions;
DROP TABLE IF EXISTS letter_disposition_actions;
DROP TABLE IF EXISTS disposition_notes;
DROP TABLE IF EXISTS letter_dispositions;
DROP TABLE IF EXISTS letter_incoming_attachments;
DROP TABLE IF EXISTS letter_incoming_labels;
DROP TABLE IF EXISTS letter_incoming_recipients;
DROP TABLE IF EXISTS letters_incoming;
DROP SEQUENCE IF EXISTS letters_incoming_seq;
COMMIT;

View File

@ -1,189 +0,0 @@
BEGIN;
-- =======================
-- SEQUENCE FOR LETTER NUMBER
-- =======================
CREATE SEQUENCE IF NOT EXISTS letters_incoming_seq;
-- =======================
-- LETTERS INCOMING
-- =======================
CREATE TABLE IF NOT EXISTS letters_incoming (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_number TEXT NOT NULL UNIQUE DEFAULT ('IN-' || lpad(nextval('letters_incoming_seq')::text, 8, '0')),
reference_number TEXT,
subject TEXT NOT NULL,
description TEXT,
priority_id UUID REFERENCES priorities(id) ON DELETE SET NULL,
sender_institution_id UUID REFERENCES institutions(id) ON DELETE SET NULL,
received_date DATE NOT NULL,
due_date DATE,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','in_progress','completed')),
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITHOUT TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_letters_incoming_status ON letters_incoming(status);
CREATE INDEX IF NOT EXISTS idx_letters_incoming_received_date ON letters_incoming(received_date);
CREATE TRIGGER trg_letters_incoming_updated_at
BEFORE UPDATE ON letters_incoming
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =======================
-- LETTER INCOMING RECIPIENTS
-- =======================
CREATE TABLE IF NOT EXISTS letter_incoming_recipients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE,
recipient_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
recipient_department_id UUID REFERENCES departments(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','read','completed')),
read_at TIMESTAMP WITHOUT TIME ZONE,
completed_at TIMESTAMP WITHOUT TIME ZONE,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_letter_incoming_recipients_letter ON letter_incoming_recipients(letter_id);
-- =======================
-- LETTER INCOMING LABELS (M:N)
-- =======================
CREATE TABLE IF NOT EXISTS letter_incoming_labels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE,
label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE (letter_id, label_id)
);
CREATE INDEX IF NOT EXISTS idx_letter_incoming_labels_letter ON letter_incoming_labels(letter_id);
-- =======================
-- LETTER INCOMING ATTACHMENTS
-- =======================
CREATE TABLE IF NOT EXISTS letter_incoming_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE,
file_url TEXT NOT NULL,
file_name TEXT NOT NULL,
file_type TEXT NOT NULL,
uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL,
uploaded_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_letter_incoming_attachments_letter ON letter_incoming_attachments(letter_id);
-- =======================
-- LETTER DISPOSITIONS
-- =======================
CREATE TABLE IF NOT EXISTS letter_dispositions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE,
from_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
from_department_id UUID REFERENCES departments(id) ON DELETE SET NULL,
to_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
to_department_id UUID REFERENCES departments(id) ON DELETE SET NULL,
notes TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','read','rejected','completed')),
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
read_at TIMESTAMP WITHOUT TIME ZONE,
completed_at TIMESTAMP WITHOUT TIME ZONE,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_letter_dispositions_letter ON letter_dispositions(letter_id);
CREATE TRIGGER trg_letter_dispositions_updated_at
BEFORE UPDATE ON letter_dispositions
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =======================
-- DISPOSITION NOTES
-- =======================
CREATE TABLE IF NOT EXISTS disposition_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
disposition_id UUID NOT NULL REFERENCES letter_dispositions(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
note TEXT NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_disposition_notes_disposition ON disposition_notes(disposition_id);
-- =======================
-- LETTER DISPOSITION ACTIONS (Selections)
-- =======================
CREATE TABLE IF NOT EXISTS letter_disposition_actions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
disposition_id UUID NOT NULL REFERENCES letter_dispositions(id) ON DELETE CASCADE,
action_id UUID NOT NULL REFERENCES disposition_actions(id) ON DELETE RESTRICT,
note TEXT,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE (disposition_id, action_id)
);
CREATE INDEX IF NOT EXISTS idx_letter_disposition_actions_disposition ON letter_disposition_actions(disposition_id);
-- =======================
-- LETTER INCOMING DISCUSSIONS (Threaded)
-- =======================
CREATE TABLE IF NOT EXISTS letter_incoming_discussions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE,
parent_id UUID REFERENCES letter_incoming_discussions(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
message TEXT NOT NULL,
mentions JSONB,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
edited_at TIMESTAMP WITHOUT TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_letter_incoming_discussions_letter ON letter_incoming_discussions(letter_id);
CREATE INDEX IF NOT EXISTS idx_letter_incoming_discussions_parent ON letter_incoming_discussions(parent_id);
CREATE TRIGGER trg_letter_incoming_discussions_updated_at
BEFORE UPDATE ON letter_incoming_discussions
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =======================
-- LETTER INCOMING DISCUSSION ATTACHMENTS
-- =======================
CREATE TABLE IF NOT EXISTS letter_incoming_discussion_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
discussion_id UUID NOT NULL REFERENCES letter_incoming_discussions(id) ON DELETE CASCADE,
file_url TEXT NOT NULL,
file_name TEXT NOT NULL,
file_type TEXT NOT NULL,
uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL,
uploaded_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_letter_incoming_discussion_attachments_discussion ON letter_incoming_discussion_attachments(discussion_id);
-- =======================
-- LETTER INCOMING ACTIVITY LOGS (Immutable)
-- =======================
CREATE TABLE IF NOT EXISTS letter_incoming_activity_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE,
action_type TEXT NOT NULL,
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
actor_department_id UUID REFERENCES departments(id) ON DELETE SET NULL,
target_type TEXT,
target_id UUID,
from_status TEXT,
to_status TEXT,
context JSONB,
occurred_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_letter_incoming_activity_logs_letter ON letter_incoming_activity_logs(letter_id);
CREATE INDEX IF NOT EXISTS idx_letter_incoming_activity_logs_action ON letter_incoming_activity_logs(action_type);
COMMIT;

View File

@ -1,5 +0,0 @@
BEGIN;
DROP TABLE IF EXISTS disposition_routes;
COMMIT;

View File

@ -1,27 +0,0 @@
BEGIN;
-- =======================
-- DISPOSITION ROUTES
-- =======================
CREATE TABLE IF NOT EXISTS disposition_routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
from_department_id UUID NOT NULL REFERENCES departments(id) ON DELETE CASCADE,
to_department_id UUID NOT NULL REFERENCES departments(id) ON DELETE CASCADE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
allowed_actions JSONB,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_disposition_routes_from_dept ON disposition_routes(from_department_id);
-- Prevent duplicate active routes from -> to
CREATE UNIQUE INDEX IF NOT EXISTS uq_disposition_routes_active
ON disposition_routes(from_department_id, to_department_id)
WHERE is_active = TRUE;
CREATE TRIGGER trg_disposition_routes_updated_at
BEFORE UPDATE ON disposition_routes
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
COMMIT;

View File

@ -1,4 +0,0 @@
BEGIN;
DROP TRIGGER IF EXISTS trg_app_settings_updated_at ON app_settings;
DROP TABLE IF EXISTS app_settings;
COMMIT;

View File

@ -1,21 +0,0 @@
BEGIN;
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TRIGGER trg_app_settings_updated_at
BEFORE UPDATE ON app_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
INSERT INTO app_settings(key, value)
VALUES
('INCOMING_LETTER_PREFIX', '{"value": "ESLI"}'::jsonb),
('INCOMING_LETTER_SEQUENCE', '{"value": 0}'::jsonb),
('INCOMING_LETTER_RECIPIENTS', '{"department_codes": ["aslog"]}'::jsonb)
ON CONFLICT (key) DO NOTHING;
COMMIT;

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;

BIN
server Executable file

Binary file not shown.