diff --git a/NOTIFICATION_API.md b/NOTIFICATION_API.md new file mode 100644 index 0000000..12a11e5 --- /dev/null +++ b/NOTIFICATION_API.md @@ -0,0 +1,278 @@ +# Notification Service API Documentation + +## Overview +The notification service integrates with Novu to manage subscribers and trigger notifications. It automatically creates/manages Novu subscribers based on user data and provides endpoints for triggering notifications. + +## Configuration + +Add your Novu credentials to `infra/development.yaml`: + +```yaml +novu: + api_key: 'your-novu-api-key' # Get from Novu dashboard + application_id: 'your-app-id' # Optional + base_url: 'https://api.novu.co' # Optional, defaults to Novu cloud +``` + +## Features + +1. **Automatic Subscriber Management** + - Subscribers are automatically created when users are created + - Subscriber ID = User ID (UUID) + - Subscribers are updated when user data changes + - Subscribers are deleted when users are deleted + +2. **On-Demand Subscriber Creation** + - If a subscriber doesn't exist when triggering a notification, it's created automatically + - Ensures notifications can always be sent + +## API Endpoints + +### Current User Endpoints (Authenticated users) + +#### 1. Trigger Notification for Current User +```http +POST /api/v1/notifications/me/trigger +Authorization: Bearer {token} + +{ + "template_id": "welcome-email", + "template_data": { + "username": "John Doe", + "action_url": "https://example.com/confirm" + }, + "overrides": { + "email": { + "subject": "Custom Subject", + "from": "custom@example.com" + } + } +} +``` + +#### 2. Get Current User's Subscriber Info +```http +GET /api/v1/notifications/me/subscriber +Authorization: Bearer {token} +``` + +Response: +```json +{ + "subscriber_id": "uuid", + "email": "user@example.com", + "first_name": "John", + "data": { + "userId": "uuid", + "roles": [...], + "departments": [...] + } +} +``` + +#### 3. Update Current User's Channel Credentials +```http +PUT /api/v1/notifications/me/channel +Authorization: Bearer {token} + +{ + "channel": "push", + "credentials": { + "deviceTokens": ["token1", "token2"] + } +} +``` + +### Admin Endpoints (Requires `notification.admin` permission) + +#### 1. Trigger Notification for Any User +```http +POST /api/v1/notifications/trigger +Authorization: Bearer {token} + +{ + "user_id": "user-uuid", + "template_id": "order-status", + "template_data": { + "orderNumber": "12345", + "status": "shipped" + }, + "to": { + "email": "override@example.com", + "phone": "+1234567890" + } +} +``` + +#### 2. Bulk Trigger Notifications +```http +POST /api/v1/notifications/bulk-trigger +Authorization: Bearer {token} + +{ + "template_id": "announcement", + "user_ids": ["uuid1", "uuid2", "uuid3"], + "template_data": { + "message": "System maintenance scheduled" + } +} +``` + +Response: +```json +{ + "success": true, + "total_sent": 3, + "total_failed": 0, + "results": [ + { + "user_id": "uuid1", + "success": true, + "transaction_id": "tx-123" + } + ] +} +``` + +#### 3. Get Any User's Subscriber Info +```http +GET /api/v1/notifications/subscribers/{userId} +Authorization: Bearer {token} +``` + +#### 4. Update Any User's Channel Credentials +```http +PUT /api/v1/notifications/subscribers/channel +Authorization: Bearer {token} + +{ + "user_id": "user-uuid", + "channel": "sms", + "credentials": { + "phoneNumber": "+1234567890" + } +} +``` + +## Notification Channels + +Supported channels: +- `email` - Email notifications +- `sms` - SMS notifications +- `push` - Push notifications +- `in_app` - In-app notifications +- `chat` - Chat notifications (Slack, Discord, etc.) + +## Template Data + +Template data is passed to Novu templates for variable substitution: + +```json +{ + "template_data": { + "username": "{{username}}", + "orderNumber": "{{orderNumber}}", + "customField": "{{customField}}" + } +} +``` + +## Overrides + +You can override notification content per channel: + +```json +{ + "overrides": { + "email": { + "subject": "Custom Subject", + "body": "Custom HTML body", + "from": "noreply@company.com" + }, + "sms": { + "content": "Custom SMS message", + "from": "+1234567890" + }, + "push": { + "title": "Custom Title", + "content": "Custom push message" + }, + "in_app": { + "content": "Custom in-app message" + } + } +} +``` + +## Error Handling + +The service handles errors gracefully: +- Missing Novu configuration: Returns success=false with appropriate message +- Missing subscriber: Automatically creates subscriber before sending +- Failed notifications: Returns detailed error messages + +## Integration with User Management + +The notification service is integrated with user management: + +1. **User Creation**: Automatically creates Novu subscriber +2. **User Update**: Updates subscriber data +3. **User Deletion**: Removes subscriber from Novu +4. **User Data Sync**: Subscriber includes user roles and departments + +## Usage Examples + +### Send Welcome Email +```javascript +// POST /api/v1/notifications/me/trigger +{ + "template_id": "welcome", + "template_data": { + "firstName": "John", + "verificationUrl": "https://app.com/verify/123" + } +} +``` + +### Send Order Confirmation +```javascript +// POST /api/v1/notifications/trigger +{ + "user_id": "customer-uuid", + "template_id": "order-confirmation", + "template_data": { + "orderNumber": "ORD-2024-001", + "items": [...], + "total": "$99.99" + } +} +``` + +### Send Bulk Announcement +```javascript +// POST /api/v1/notifications/bulk-trigger +{ + "template_id": "system-announcement", + "user_ids": ["uuid1", "uuid2", "uuid3"], + "template_data": { + "title": "Scheduled Maintenance", + "message": "System will be down for maintenance on Sunday", + "startTime": "2024-01-14 00:00", + "endTime": "2024-01-14 04:00" + } +} +``` + +## Testing + +1. Ensure Novu API key is configured +2. Create a test user +3. Create a notification template in Novu dashboard +4. Use the template ID to send test notifications + +## Troubleshooting + +1. **"notification service not configured"**: Add Novu API key to configuration +2. **"failed to create subscriber"**: Check user data and Novu API key +3. **"template not found"**: Ensure template exists in Novu dashboard +4. **No notification received**: Check Novu dashboard for delivery status \ No newline at end of file diff --git a/config/configs.go b/config/configs.go index 9063f7f..d50518d 100644 --- a/config/configs.go +++ b/config/configs.go @@ -30,6 +30,7 @@ type Config struct { Log Log `mapstructure:"log"` S3Config S3Config `mapstructure:"s3"` OnlyOffice OnlyOffice `mapstructure:"onlyoffice"` + Novu Novu `mapstructure:"novu"` } var ( @@ -85,3 +86,10 @@ type OnlyOffice struct { URL string `mapstructure:"url"` Token string `mapstructure:"token"` } + +type Novu struct { + APIKey string `mapstructure:"api_key"` + ApplicationID string `mapstructure:"application_id"` + BaseURL string `mapstructure:"base_url"` + IncomingLetterWorkflowID string `mapstructure:"incoming_letter_workflow_id"` +} diff --git a/eslogad-be b/eslogad-be new file mode 100755 index 0000000..5285e4a Binary files /dev/null and b/eslogad-be differ diff --git a/go.mod b/go.mod index 881c7af..3419c97 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,9 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/novuhq/go-novu v0.1.2 // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/spf13/afero v1.9.5 // indirect diff --git a/go.sum b/go.sum index 02f23ca..1ecfcd2 100644 --- a/go.sum +++ b/go.sum @@ -209,6 +209,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/novuhq/go-novu v0.1.2 h1:hYVrVjZBUgByVwLE+W4DNXRRCBlHoNNOBLkDI7/enU8= +github.com/novuhq/go-novu v0.1.2/go.mod h1:O8+kHDKSfDncLZ8olp5FL00tn1aSTMOvZI1IRZZqmUg= github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/infra/development.yaml b/infra/development.yaml index 5c4fc8d..fca273a 100644 --- a/infra/development.yaml +++ b/infra/development.yaml @@ -35,4 +35,10 @@ log: onlyoffice: url: 'https://onlyoffice.apskel.org/' - token: '2DmKgd5PT3n1vH3f2v2iRZUqTVHj9GQx' \ No newline at end of file + token: '2DmKgd5PT3n1vH3f2v2iRZUqTVHj9GQx' + +novu: + api_key: 'f7de60a16abf825996191bf69ea8054a' # Add your Novu API key here + application_id: 'cDeX8L5VWe-r' # Add your Novu Application ID here + base_url: 'https://novu-api.apskel.org' # Optional: defaults to https://api.novu.co + incoming_letter_workflow_id: 'notification-dashbpard' \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go index 8c77612..6806b83 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,6 +11,7 @@ import ( "eslogad-be/config" "eslogad-be/internal/client" + internalConfig "eslogad-be/internal/config" "eslogad-be/internal/handler" "eslogad-be/internal/middleware" "eslogad-be/internal/processor" @@ -51,6 +52,7 @@ func (a *App) Initialize(cfg *config.Config) error { dispositionRouteHandler := handler.NewDispositionRouteHandler(services.dispositionRouteService) onlyOfficeHandler := handler.NewOnlyOfficeHandler(services.onlyOfficeService) analyticsHandler := handler.NewAnalyticsHandler(services.analyticsService) + notificationHandler := handler.NewNotificationHandler(services.notificationService) a.router = router.NewRouter( cfg, @@ -67,6 +69,7 @@ func (a *App) Initialize(cfg *config.Config) error { dispositionRouteHandler, onlyOfficeHandler, analyticsHandler, + notificationHandler, ) return nil @@ -182,32 +185,39 @@ func (a *App) initRepositories() *repositories { } type processors struct { - userProcessor *processor.UserProcessorImpl - letterProcessor *processor.LetterProcessorImpl - letterOutgoingProcessor *processor.LetterOutgoingProcessorImpl - activityLogger *processor.ActivityLogProcessorImpl - letterNumberGenerator *processor.LetterNumberGeneratorImpl - onlyOfficeProcessor *processor.OnlyOfficeProcessorImpl + userProcessor *processor.UserProcessorImpl + cachedUserProcessor *processor.CachedUserProcessor + letterProcessor *processor.LetterProcessorImpl + letterOutgoingProcessor *processor.LetterOutgoingProcessorImpl + activityLogger *processor.ActivityLogProcessorImpl + letterNumberGenerator *processor.LetterNumberGeneratorImpl + onlyOfficeProcessor *processor.OnlyOfficeProcessorImpl + novuProcessor processor.NovuProcessor + notificationProcessor processor.NotificationProcessor + recipientProcessor *processor.RecipientProcessorImpl + letterDispositionProcessor *processor.LetterDispositionProcessorImpl + letterDispositionDeptProcessor *processor.LetterDispositionDepartmentProcessorImpl } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { txMgr := repository.NewTxManager(a.db) activity := processor.NewActivityLogProcessor(repos.activityLogRepo) - + // Create the letter number generator letterNumberGen := processor.NewLetterNumberGenerator(repos.settingRepo) - + // Create letter processors with the number generator letterProc := 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.recipientRepo, repos.letterOutgoingRecipientRepo, + repos.departmentRepo, repos.userDeptRepo, repos.priorityRepo, repos.institutionRepo, repos.dispRepo, - letterNumberGen, + letterNumberGen, repos.dispositionRouteRepo, ) - + letterOutgoingProc := processor.NewLetterOutgoingProcessor( a.db, repos.letterOutgoingRepo, @@ -220,14 +230,16 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor repos.letterOutgoingApprovalRepo, letterNumberGen, txMgr, + repos.priorityRepo, + repos.institutionRepo, ) - + // Create document repositories docSessionRepo := repository.NewDocumentSessionRepository(a.db) docVersionRepo := repository.NewDocumentVersionRepository(a.db) docMetadataRepo := repository.NewDocumentMetadataRepository(a.db) docErrorRepo := repository.NewDocumentErrorRepository(a.db) - + // Create OnlyOffice processor onlyOfficeProc := processor.NewOnlyOfficeProcessor( a.db, @@ -237,14 +249,61 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor docErrorRepo, txMgr, ) + + // Create Novu processor for backward compatibility + novuConfig := internalConfig.LoadNovuConfig(cfg) + novuProc := processor.NewNovuProcessor(novuConfig) + // Create notification processor with Novu provider + novuProvider := processor.NewNovuProvider(novuConfig) + notificationProc := processor.NewNotificationProcessor(novuProvider, novuConfig.IncomingLetterWorkflowID) + + // Create user processor with Novu integration + userProc := processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo) + userProc.SetNovuProcessor(novuProc) + + // Create cached user processor for auth middleware + cachedUserProc := processor.NewCachedUserProcessor(repos.userRepo, repos.userProfileRepo) + + // Create recipient processor + recipientProc := processor.NewRecipientProcessor( + repos.recipientRepo, + repos.settingRepo, + repos.departmentRepo, + repos.userDeptRepo, + ) + + // Create letter disposition processor + letterDispositionProc := processor.NewLetterDispositionProcessor( + repos.letterDispositionRepo, + repos.letterDispositionDeptRepo, + repos.letterDispActionSelRepo, + repos.dispositionNoteRepo, + repos.letterDiscussionRepo, + repos.dispRepo, + activity, + ) + + // Create letter disposition department processor + letterDispositionDeptProc := processor.NewLetterDispositionDepartmentProcessor( + repos.letterDispositionDeptRepo, + repos.dispositionNoteRepo, + repos.letterRepo, + ) + return &processors{ - userProcessor: processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo), - letterProcessor: letterProc, - letterOutgoingProcessor: letterOutgoingProc, - activityLogger: activity, - letterNumberGenerator: letterNumberGen, - onlyOfficeProcessor: onlyOfficeProc, + userProcessor: userProc, + cachedUserProcessor: cachedUserProc, + letterProcessor: letterProc, + letterOutgoingProcessor: letterOutgoingProc, + activityLogger: activity, + letterNumberGenerator: letterNumberGen, + onlyOfficeProcessor: onlyOfficeProc, + novuProcessor: novuProc, + notificationProcessor: notificationProc, + recipientProcessor: recipientProc, + letterDispositionProcessor: letterDispositionProc, + letterDispositionDeptProcessor: letterDispositionDeptProc, } } @@ -260,6 +319,7 @@ type services struct { dispositionRouteService *service.DispositionRouteServiceImpl onlyOfficeService *service.OnlyOfficeServiceImpl analyticsService *service.AnalyticsServiceImpl + notificationService *service.NotificationServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -277,10 +337,17 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con masterSvc := service.NewMasterService(repos.labelRepo, repos.priorityRepo, repos.institutionRepo, repos.dispRepo, repos.departmentRepo) - letterSvc := service.NewLetterService(processors.letterProcessor) - dispRouteSvc := service.NewDispositionRouteService(repos.dispositionRouteRepo) - txManager := repository.NewTxManager(a.db) + letterSvc := service.NewLetterService( + processors.letterProcessor, + txManager, + processors.letterNumberGenerator, + processors.recipientProcessor, + processors.activityLogger, + processors.letterDispositionProcessor, + processors.notificationProcessor, + ) + dispRouteSvc := service.NewDispositionRouteService(repos.dispositionRouteRepo) letterOutgoingSvc := service.NewLetterOutgoingService(processors.letterOutgoingProcessor) approvalFlowStepRepo := repository.NewApprovalFlowStepRepository(a.db) @@ -293,10 +360,14 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con // Create OnlyOffice service with file storage onlyOfficeSvc := service.NewOnlyOfficeService(processors.onlyOfficeProcessor, &cfg.OnlyOffice, a.db, s3Client) - + // Create Analytics service analyticsSvc := service.NewAnalyticsService(repos.analyticsRepo) - + + // Create Notification service + novuConfig := internalConfig.LoadNovuConfig(cfg) + notificationSvc := service.NewNotificationService(novuConfig, processors.userProcessor) + return &services{ userService: userSvc, authService: authService, @@ -309,6 +380,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con dispositionRouteService: dispRouteSvc, onlyOfficeService: onlyOfficeSvc, analyticsService: analyticsSvc, + notificationService: notificationSvc, } } diff --git a/internal/config/novu.go b/internal/config/novu.go new file mode 100644 index 0000000..285d7f0 --- /dev/null +++ b/internal/config/novu.go @@ -0,0 +1,30 @@ +package config + +import "eslogad-be/config" + +type NovuConfig struct { + APIKey string + ApplicationID string + BaseURL string + IncomingLetterWorkflowID string +} + +func LoadNovuConfig(cfg *config.Config) *NovuConfig { + baseURL := cfg.Novu.BaseURL + if baseURL == "" { + baseURL = "https://api.novu.co" + } + + // Default workflow ID for incoming letter notifications + workflowID := cfg.Novu.IncomingLetterWorkflowID + if workflowID == "" { + workflowID = "notification-dashbpard" + } + + return &NovuConfig{ + APIKey: cfg.Novu.APIKey, + ApplicationID: cfg.Novu.ApplicationID, + BaseURL: baseURL, + IncomingLetterWorkflowID: workflowID, + } +} diff --git a/internal/constant/system.go b/internal/constant/system.go new file mode 100644 index 0000000..000b8b0 --- /dev/null +++ b/internal/constant/system.go @@ -0,0 +1,24 @@ +package constant + +import "github.com/google/uuid" + +const ( + // SystemUUID is the UUID used for system-generated entities + SystemUUIDString = "11111111-2222-3333-4444-555555555555" +) + +var ( + // SystemUserID is the UUID for the system user + SystemUserID = uuid.MustParse(SystemUUIDString) + + // SystemDepartmentID is the UUID for the system department + SystemDepartmentID = uuid.MustParse(SystemUUIDString) +) + +// System entity constants +const ( + SystemUserEmail = "system@eslogad.internal" + SystemUserName = "System User" + SystemDeptCode = "SYSTEM" + SystemDeptName = "System" +) \ No newline at end of file diff --git a/internal/constants/constant.go b/internal/constants/constant.go index eaa22a6..d75c2df 100644 --- a/internal/constants/constant.go +++ b/internal/constants/constant.go @@ -15,3 +15,7 @@ var ValidCountryCodeMap = map[string]bool{ "SG": true, "TH": true, } + +const ( + AppSettingLetterIncomingReceipients = "" +) diff --git a/internal/contract/common.go b/internal/contract/common.go index a4dcbb7..8067286 100644 --- a/internal/contract/common.go +++ b/internal/contract/common.go @@ -3,11 +3,11 @@ package contract import "time" const ( - SettingIncomingLetterPrefix = "INCOMING_LETTER_PREFIX" - SettingIncomingLetterSequence = "INCOMING_LETTER_SEQUENCE" - SettingIncomingLetterRecipients = "INCOMING_LETTER_RECIPIENTS" - SettingOutgoingLetterPrefix = "OUTGOING_LETTER_PREFIX" - SettingOutgoingLetterSequence = "OUTGOING_LETTER_SEQUENCE" + SettingIncomingLetterPrefix = "INCOMING_LETTER_PREFIX" + SettingIncomingLetterSequence = "INCOMING_LETTER_SEQUENCE" + SettingIncomingLetterDepartmentRecipients = "INCOMING_LETTER_DEPARTMENT_RECIPIENTS" + SettingOutgoingLetterPrefix = "OUTGOING_LETTER_PREFIX" + SettingOutgoingLetterSequence = "OUTGOING_LETTER_SEQUENCE" ) type ErrorResponse struct { diff --git a/internal/contract/disposition_route_contract.go b/internal/contract/disposition_route_contract.go index e3d0b32..3827108 100644 --- a/internal/contract/disposition_route_contract.go +++ b/internal/contract/disposition_route_contract.go @@ -27,12 +27,19 @@ type DispositionRouteResponse struct { } type CreateDispositionRouteRequest struct { - FromDepartmentID uuid.UUID `json:"from_department_id"` - ToDepartmentID uuid.UUID `json:"to_department_id"` + FromDepartmentID uuid.UUID `json:"from_department_id" binding:"required"` + ToDepartmentIDs []uuid.UUID `json:"to_department_ids" binding:"required,min=1"` IsActive *bool `json:"is_active,omitempty"` AllowedActions *map[string]interface{} `json:"allowed_actions,omitempty"` } +// BulkCreateDispositionRouteResponse response for bulk create/update operation +type BulkCreateDispositionRouteResponse struct { + Created int `json:"created"` + Updated int `json:"updated"` + Routes []DispositionRouteResponse `json:"routes"` +} + type UpdateDispositionRouteRequest struct { IsActive *bool `json:"is_active,omitempty"` AllowedActions *map[string]interface{} `json:"allowed_actions,omitempty"` @@ -41,3 +48,26 @@ type UpdateDispositionRouteRequest struct { type ListDispositionRoutesResponse struct { Routes []DispositionRouteResponse `json:"routes"` } + +// DepartmentMapping represents department ID and name mapping +type DepartmentMapping struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` +} + +// DispositionRouteGroupedItem represents a single grouped route item with clean department structure +type DispositionRouteGroupedItem struct { + FromDepartment DepartmentMapping `json:"from_department"` + ToDepartments []DepartmentMapping `json:"to_departments"` +} + +// ListDispositionRoutesGroupedResponse returns all routes grouped by from_department_id +type ListDispositionRoutesGroupedResponse struct { + Dispositions []DispositionRouteGroupedItem `json:"dispositions"` +} + +// ListDispositionRoutesDetailedResponse returns all routes with department details +type ListDispositionRoutesDetailedResponse struct { + Routes []DispositionRouteResponse `json:"routes"` + Total int `json:"total"` +} diff --git a/internal/contract/letter_contract.go b/internal/contract/letter_contract.go index a1d1c68..36e2cbb 100644 --- a/internal/contract/letter_contract.go +++ b/internal/contract/letter_contract.go @@ -13,6 +13,7 @@ type CreateIncomingLetterAttachment struct { } type CreateIncomingLetterRequest struct { + LetterNumber string `json:"-"` // Generated by service layer ReferenceNumber *string `json:"reference_number,omitempty"` Subject string `json:"subject"` Description *string `json:"description,omitempty"` @@ -46,6 +47,7 @@ type IncomingLetterResponse struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Attachments []IncomingLetterAttachmentResponse `json:"attachments"` + IsRead bool `json:"is_read"` } type UpdateIncomingLetterRequest struct { @@ -60,16 +62,35 @@ type UpdateIncomingLetterRequest struct { } type ListIncomingLettersRequest struct { - Page int `json:"page"` - Limit int `json:"limit"` - Status *string `json:"status,omitempty"` - Query *string `json:"query,omitempty"` - DepartmentID *uuid.UUID + Page int `json:"page"` + Limit int `json:"limit"` + Status *string `json:"status,omitempty"` + Query *string `json:"query,omitempty"` + DepartmentID *uuid.UUID + IsRead *bool `json:"is_read,omitempty"` + PriorityIDs []uuid.UUID `json:"priority_ids,omitempty"` + IsDispositioned *bool `json:"is_dispositioned,omitempty"` + IsArchived *bool `json:"is_archived,omitempty"` } type ListIncomingLettersResponse struct { - Letters []IncomingLetterResponse `json:"letters"` - Pagination PaginationResponse `json:"pagination"` + Letters []IncomingLetterResponse `json:"letters"` + Pagination PaginationResponse `json:"pagination"` + TotalUnread int `json:"total_unread"` +} + +type LetterUnreadCountResponse struct { + IncomingLetter struct { + Unread int `json:"unread"` + } `json:"incoming_letter"` + OutgoingLetter struct { + Unread int `json:"unread"` + } `json:"outgoing_letter"` +} + +type MarkLetterReadResponse struct { + Success bool `json:"success"` + Message string `json:"message"` } type CreateDispositionActionSelection struct { @@ -83,6 +104,7 @@ type CreateLetterDispositionRequest struct { ToDepartmentIDs []uuid.UUID `json:"to_department_ids"` Notes *string `json:"notes,omitempty"` SelectedActions []CreateDispositionActionSelection `json:"selected_actions,omitempty"` + CreatedBy uuid.UUID `json:"created_by"` } type DispositionResponse struct { @@ -144,6 +166,38 @@ type ListEnhancedDispositionsResponse struct { Discussions []LetterDiscussionResponse `json:"discussions"` } +type GetDepartmentDispositionStatusRequest struct { + LetterIncomingID uuid.UUID `json:"letter_incoming_id"` + DepartmentID uuid.UUID `json:"department_id"` +} + +type DepartmentDispositionStatusResponse struct { + ID uuid.UUID `json:"id"` + LetterID uuid.UUID `json:"letter_id"` + Letter *IncomingLetterResponse `json:"letter,omitempty"` + FromDepartmentID *uuid.UUID `json:"from_department_id,omitempty"` + FromDepartment *DepartmentResponse `json:"from_department,omitempty"` + ToDepartmentID uuid.UUID `json:"to_department_id"` + ToDepartment *DepartmentResponse `json:"to_department,omitempty"` + Status string `json:"status"` + Notes *string `json:"notes,omitempty"` + ReadAt *time.Time `json:"read_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListDepartmentDispositionStatusResponse struct { + Dispositions []DepartmentDispositionStatusResponse `json:"dispositions"` + Pagination PaginationResponse `json:"pagination"` +} + +type UpdateDispositionStatusRequest struct { + LetterIncomingID uuid.UUID `json:"letter_incoming_id"` + Status string `json:"status"` + Notes *string `json:"notes,omitempty"` +} + type CreateLetterDiscussionRequest struct { ParentID *uuid.UUID `json:"parent_id,omitempty"` Message string `json:"message"` @@ -172,3 +226,33 @@ type LetterDiscussionResponse struct { // Preloaded user profiles for mentions MentionedUsers []UserResponse `json:"mentioned_users,omitempty"` } + +type GetLetterCTARequest struct { + LetterIncomingID uuid.UUID `json:"letter_incoming_id"` +} + +type LetterCTAAction struct { + Type string `json:"type"` // "create_disposition", "update_status", "view" + Label string `json:"label"` // Human-readable label for the action + Path string `json:"path"` // API endpoint path + Method string `json:"method"` // HTTP method: GET, POST, PUT, etc. + Description string `json:"description"` // Description of what this action does +} + +type LetterCTAResponse struct { + LetterIncomingID uuid.UUID `json:"letter_incoming_id"` + Actions []LetterCTAAction `json:"actions"` + DispositionID *uuid.UUID `json:"disposition_id,omitempty"` + CurrentStatus *string `json:"current_status,omitempty"` + Message string `json:"message"` +} + +type BulkArchiveLettersRequest struct { + LetterIDs []uuid.UUID `json:"letter_ids" binding:"required,min=1"` +} + +type BulkArchiveLettersResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + ArchivedCount int `json:"archived_count"` +} diff --git a/internal/contract/letter_outgoing_contract.go b/internal/contract/letter_outgoing_contract.go index 5009431..4ce134a 100644 --- a/internal/contract/letter_outgoing_contract.go +++ b/internal/contract/letter_outgoing_contract.go @@ -111,6 +111,7 @@ type ListOutgoingLettersRequest struct { PriorityID *uuid.UUID `form:"priority_id" json:"priority_id,omitempty"` SortBy string `form:"sort_by" json:"sort_by,omitempty"` SortOrder string `form:"sort_order" json:"sort_order,omitempty"` + IsArchived *bool `form:"is_archived" json:"is_archived,omitempty"` } type ListOutgoingLettersResponse struct { diff --git a/internal/contract/notification_contract.go b/internal/contract/notification_contract.go new file mode 100644 index 0000000..12fb768 --- /dev/null +++ b/internal/contract/notification_contract.go @@ -0,0 +1,107 @@ +package contract + +import "github.com/google/uuid" + +type TriggerNotificationRequest struct { + UserID uuid.UUID `json:"user_id" validate:"required"` + TemplateID string `json:"template_id" validate:"required"` + TemplateData map[string]interface{} `json:"template_data,omitempty"` + To *NotificationTo `json:"to,omitempty"` + Overrides *NotificationOverrides `json:"overrides,omitempty"` +} + +type NotificationTo struct { + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + SubscriberID string `json:"subscriber_id,omitempty"` +} + +type NotificationOverrides struct { + Email *EmailOverride `json:"email,omitempty"` + SMS *SMSOverride `json:"sms,omitempty"` + InApp *InAppOverride `json:"in_app,omitempty"` + Push *PushOverride `json:"push,omitempty"` + Chat *ChatOverride `json:"chat,omitempty"` +} + +type EmailOverride struct { + Subject string `json:"subject,omitempty"` + Body string `json:"body,omitempty"` + From string `json:"from,omitempty"` +} + +type SMSOverride struct { + Content string `json:"content,omitempty"` + From string `json:"from,omitempty"` +} + +type InAppOverride struct { + Content string `json:"content,omitempty"` +} + +type PushOverride struct { + Title string `json:"title,omitempty"` + Content string `json:"content,omitempty"` +} + +type ChatOverride struct { + Content string `json:"content,omitempty"` +} + +type TriggerNotificationResponse struct { + Success bool `json:"success"` + TransactionID string `json:"transaction_id,omitempty"` + Message string `json:"message,omitempty"` +} + +type BulkTriggerNotificationRequest struct { + TemplateID string `json:"template_id" validate:"required"` + UserIDs []uuid.UUID `json:"user_ids" validate:"required,min=1"` + TemplateData map[string]interface{} `json:"template_data,omitempty"` + Overrides *NotificationOverrides `json:"overrides,omitempty"` +} + +type BulkTriggerNotificationResponse struct { + Success bool `json:"success"` + TotalSent int `json:"total_sent"` + TotalFailed int `json:"total_failed"` + Results []NotificationResult `json:"results,omitempty"` +} + +type NotificationResult struct { + UserID uuid.UUID `json:"user_id"` + Success bool `json:"success"` + TransactionID string `json:"transaction_id,omitempty"` + Error string `json:"error,omitempty"` +} + +type GetSubscriberRequest struct { + UserID uuid.UUID `json:"user_id" validate:"required"` +} + +type GetSubscriberResponse struct { + SubscriberID string `json:"subscriber_id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Phone string `json:"phone,omitempty"` + Avatar string `json:"avatar,omitempty"` + Data map[string]interface{} `json:"data,omitempty"` + Channels []ChannelCredentials `json:"channels,omitempty"` +} + +type ChannelCredentials struct { + Channel string `json:"channel"` + Credentials map[string]interface{} `json:"credentials"` +} + +type UpdateSubscriberChannelRequest struct { + UserID uuid.UUID `json:"user_id" validate:"required"` + Channel string `json:"channel" validate:"required,oneof=email sms push chat in_app"` + Credentials map[string]interface{} `json:"credentials" validate:"required"` +} + +type UpdateSubscriberChannelResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} \ No newline at end of file diff --git a/internal/entities/department_recipients_setting.go b/internal/entities/department_recipients_setting.go new file mode 100644 index 0000000..9ab602d --- /dev/null +++ b/internal/entities/department_recipients_setting.go @@ -0,0 +1,8 @@ +package entities + +import "github.com/google/uuid" + +// DepartmentRecipientsSetting represents the structure for storing department recipient IDs in app settings +type DepartmentRecipientsSetting struct { + DepartmentIDs []uuid.UUID `json:"department_ids"` +} \ No newline at end of file diff --git a/internal/entities/letter_disposition.go b/internal/entities/letter_disposition.go index 9ef16f1..916da88 100644 --- a/internal/entities/letter_disposition.go +++ b/internal/entities/letter_disposition.go @@ -23,14 +23,31 @@ type LetterIncomingDisposition struct { func (LetterIncomingDisposition) TableName() string { return "letter_incoming_dispositions" } +type LetterIncomingDispositionDepartmentStatus string + +const ( + DispositionDepartmentStatusPending LetterIncomingDispositionDepartmentStatus = "pending" + DispositionDepartmentStatusDispositioned LetterIncomingDispositionDepartmentStatus = "dispositioned" + DispositionDepartmentStatusRead LetterIncomingDispositionDepartmentStatus = "read" + DispositionDepartmentStatusCompleted LetterIncomingDispositionDepartmentStatus = "completed" +) + 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"` + 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"` + LetterIncomingID uuid.UUID `gorm:"type:uuid;not null" json:"letter_incoming_id"` + DepartmentID uuid.UUID `gorm:"type:uuid;not null" json:"department_id"` + Status LetterIncomingDispositionDepartmentStatus `gorm:"not null;default:'pending'" json:"status"` + Notes *string `gorm:"type:text" json:"notes,omitempty"` + ReadAt *time.Time `json:"read_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` // Relationships - Department *Department `gorm:"foreignKey:DepartmentID;references:ID" json:"department,omitempty"` + Department *Department `gorm:"foreignKey:DepartmentID;references:ID" json:"department,omitempty"` + LetterIncoming *LetterIncoming `gorm:"foreignKey:LetterIncomingID;references:ID" json:"letter_incoming,omitempty"` + LetterIncomingDisposition *LetterIncomingDisposition `gorm:"foreignKey:LetterIncomingDispositionID;references:ID" json:"letter_incoming_disposition,omitempty"` } func (LetterIncomingDispositionDepartment) TableName() string { diff --git a/internal/handler/disposition_route_handler.go b/internal/handler/disposition_route_handler.go index 1b7985b..816a664 100644 --- a/internal/handler/disposition_route_handler.go +++ b/internal/handler/disposition_route_handler.go @@ -12,10 +12,13 @@ import ( type DispositionRouteService interface { Create(ctx context.Context, req *contract.CreateDispositionRouteRequest) (*contract.DispositionRouteResponse, error) + CreateOrUpdate(ctx context.Context, req *contract.CreateDispositionRouteRequest) (*contract.BulkCreateDispositionRouteResponse, error) Update(ctx context.Context, id uuid.UUID, req *contract.UpdateDispositionRouteRequest) (*contract.DispositionRouteResponse, error) Get(ctx context.Context, id uuid.UUID) (*contract.DispositionRouteResponse, error) ListByFromDept(ctx context.Context, from uuid.UUID) (*contract.ListDispositionRoutesResponse, error) SetActive(ctx context.Context, id uuid.UUID, active bool) error + ListGrouped(ctx context.Context) (*contract.ListDispositionRoutesGroupedResponse, error) + ListAll(ctx context.Context) (*contract.ListDispositionRoutesDetailedResponse, error) } type DispositionRouteHandler struct{ svc DispositionRouteService } @@ -24,18 +27,61 @@ func NewDispositionRouteHandler(svc DispositionRouteService) *DispositionRouteHa return &DispositionRouteHandler{svc: svc} } +// Create handles both single and bulk route creation with upsert logic func (h *DispositionRouteHandler) Create(c *gin.Context) { var req contract.CreateDispositionRouteRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400}) + c.JSON(400, &contract.ErrorResponse{Error: "invalid body: " + err.Error(), Code: 400}) return } - resp, err := h.svc.Create(c.Request.Context(), &req) + + // Validate request + if len(req.ToDepartmentIDs) == 0 { + c.JSON(400, &contract.ErrorResponse{Error: "to_department_ids cannot be empty", Code: 400}) + return + } + + // If single route, use Create for backward compatibility + if len(req.ToDepartmentIDs) == 1 { + resp, err := h.svc.Create(c.Request.Context(), &req) + if err != nil { + c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(201, contract.BuildSuccessResponse(resp)) + return + } + + // For multiple routes, use bulk create/update + bulkResp, err := h.svc.CreateOrUpdate(c.Request.Context(), &req) if err != nil { c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) return } - c.JSON(201, contract.BuildSuccessResponse(resp)) + c.JSON(201, contract.BuildSuccessResponse(bulkResp)) +} + +// BulkCreateOrUpdate explicitly handles bulk create/update operations +func (h *DispositionRouteHandler) BulkCreateOrUpdate(c *gin.Context) { + var req contract.CreateDispositionRouteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, &contract.ErrorResponse{Error: "invalid body: " + err.Error(), Code: 400}) + return + } + + // Validate request + if len(req.ToDepartmentIDs) == 0 { + c.JSON(400, &contract.ErrorResponse{Error: "to_department_ids cannot be empty", Code: 400}) + return + } + + resp, err := h.svc.CreateOrUpdate(c.Request.Context(), &req) + if err != nil { + c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + + c.JSON(200, contract.BuildSuccessResponse(resp)) } func (h *DispositionRouteHandler) Update(c *gin.Context) { @@ -96,3 +142,23 @@ func (h *DispositionRouteHandler) SetActive(c *gin.Context) { } c.JSON(200, &contract.SuccessResponse{Message: "updated"}) } + +// ListGrouped returns all disposition routes grouped by from_department_id +func (h *DispositionRouteHandler) ListGrouped(c *gin.Context) { + resp, err := h.svc.ListGrouped(c.Request.Context()) + if err != nil { + c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(200, contract.BuildSuccessResponse(resp)) +} + +// ListAll returns all disposition routes with department details +func (h *DispositionRouteHandler) ListAll(c *gin.Context) { + resp, err := h.svc.ListAll(c.Request.Context()) + if err != nil { + c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(200, contract.BuildSuccessResponse(resp)) +} diff --git a/internal/handler/letter_handler.go b/internal/handler/letter_handler.go index fbf5811..1dfa287 100644 --- a/internal/handler/letter_handler.go +++ b/internal/handler/letter_handler.go @@ -5,6 +5,7 @@ import ( "eslogad-be/internal/appcontext" "net/http" "strconv" + "strings" "eslogad-be/internal/contract" @@ -16,14 +17,23 @@ type LetterService interface { CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) GetIncomingLetterByID(ctx context.Context, id uuid.UUID) (*contract.IncomingLetterResponse, error) ListIncomingLetters(ctx context.Context, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error) + GetLetterUnreadCounts(ctx context.Context) (*contract.LetterUnreadCountResponse, error) + MarkIncomingLetterAsRead(ctx context.Context, letterID uuid.UUID) (*contract.MarkLetterReadResponse, error) + MarkOutgoingLetterAsRead(ctx context.Context, letterID uuid.UUID) (*contract.MarkLetterReadResponse, error) UpdateIncomingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error + BulkArchiveIncomingLetters(ctx context.Context, letterIDs []uuid.UUID) (*contract.BulkArchiveLettersResponse, error) CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) + + GetDepartmentDispositionStatus(ctx context.Context, req *contract.GetDepartmentDispositionStatusRequest) (*contract.ListDepartmentDispositionStatusResponse, error) + UpdateDispositionStatus(ctx context.Context, req *contract.UpdateDispositionStatusRequest) (*contract.DepartmentDispositionStatusResponse, error) + + GetLetterCTA(ctx context.Context, letterID uuid.UUID) (*contract.LetterCTAResponse, error) } type LetterHandler struct { @@ -119,6 +129,46 @@ func (h *LetterHandler) ListIncomingLetters(c *gin.Context) { h.respondSuccess(c, http.StatusOK, resp) } +func (h *LetterHandler) GetLetterUnreadCounts(c *gin.Context) { + resp, err := h.svc.GetLetterUnreadCounts(c.Request.Context()) + if err != nil { + h.handleServiceError(c, err) + return + } + + h.respondSuccess(c, http.StatusOK, resp) +} + +func (h *LetterHandler) MarkIncomingLetterAsRead(c *gin.Context) { + id, ok := h.parseUUID(c, "id") + if !ok { + return + } + + resp, err := h.svc.MarkIncomingLetterAsRead(c.Request.Context(), id) + if err != nil { + h.handleServiceError(c, err) + return + } + + h.respondSuccess(c, http.StatusOK, resp) +} + +func (h *LetterHandler) MarkOutgoingLetterAsRead(c *gin.Context) { + id, ok := h.parseUUID(c, "id") + if !ok { + return + } + + resp, err := h.svc.MarkOutgoingLetterAsRead(c.Request.Context(), id) + if err != nil { + h.handleServiceError(c, err) + return + } + + h.respondSuccess(c, http.StatusOK, resp) +} + func (h *LetterHandler) parseListRequest(c *gin.Context) *contract.ListIncomingLettersRequest { //appCtx := appcontext.FromGinContext(c) //departmentID := appCtx.DepartmentID @@ -145,10 +195,50 @@ func (h *LetterHandler) parseListRequest(c *gin.Context) *contract.ListIncomingL if status := c.Query("status"); status != "" { req.Status = &status } + if query := c.Query("q"); query != "" { req.Query = &query } + // Parse is_read filter + if isReadStr := c.Query("is_read"); isReadStr != "" { + isRead := isReadStr == "true" || isReadStr == "1" + req.IsRead = &isRead + } + + // Parse priority_ids filter + if priorityIDsStr := c.QueryArray("priority_ids[]"); len(priorityIDsStr) > 0 { + priorityIDs := make([]uuid.UUID, 0, len(priorityIDsStr)) + for _, idStr := range priorityIDsStr { + if id, err := uuid.Parse(idStr); err == nil { + priorityIDs = append(priorityIDs, id) + } + } + req.PriorityIDs = priorityIDs + } else if priorityIDStr := c.Query("priority_ids"); priorityIDStr != "" { + // Also support comma-separated format + idStrs := strings.Split(priorityIDStr, ",") + priorityIDs := make([]uuid.UUID, 0, len(idStrs)) + for _, idStr := range idStrs { + if id, err := uuid.Parse(strings.TrimSpace(idStr)); err == nil { + priorityIDs = append(priorityIDs, id) + } + } + req.PriorityIDs = priorityIDs + } + + // Parse is_dispositioned filter + if isDispositionedStr := c.Query("is_dispositioned"); isDispositionedStr != "" { + isDispositioned := isDispositionedStr == "true" || isDispositionedStr == "1" + req.IsDispositioned = &isDispositioned + } + + // Parse is_archived filter + if isArchivedStr := c.Query("is_archived"); isArchivedStr != "" { + isArchived := isArchivedStr == "true" || isArchivedStr == "1" + req.IsArchived = &isArchived + } + //req.DepartmentID = &departmentID return req @@ -268,3 +358,82 @@ func (h *LetterHandler) UpdateDiscussion(c *gin.Context) { h.respondSuccess(c, http.StatusOK, resp) } + +func (h *LetterHandler) GetDepartmentDispositionStatus(c *gin.Context) { + letterID, ok := h.parseUUID(c, "letter_id") + if !ok { + return + } + + departmentID := appcontext.FromGinContext(c.Request.Context()).DepartmentID + + req := &contract.GetDepartmentDispositionStatusRequest{ + LetterIncomingID: letterID, + DepartmentID: departmentID, + } + + resp, err := h.svc.GetDepartmentDispositionStatus(c.Request.Context(), req) + if err != nil { + h.handleServiceError(c, err) + return + } + + h.respondSuccess(c, http.StatusOK, resp) +} + +func (h *LetterHandler) UpdateDispositionStatus(c *gin.Context) { + letterID, ok := h.parseUUID(c, "letter_id") + if !ok { + return + } + + var req contract.UpdateDispositionStatusRequest + if !h.bindJSON(c, &req) { + return + } + + req.LetterIncomingID = letterID + + resp, err := h.svc.UpdateDispositionStatus(c.Request.Context(), &req) + if err != nil { + h.handleServiceError(c, err) + return + } + + h.respondSuccess(c, http.StatusOK, resp) +} + +func (h *LetterHandler) GetLetterCTA(c *gin.Context) { + letterID, ok := h.parseUUID(c, "letter_id") + if !ok { + return + } + + resp, err := h.svc.GetLetterCTA(c.Request.Context(), letterID) + if err != nil { + h.handleServiceError(c, err) + return + } + + h.respondSuccess(c, http.StatusOK, resp) +} + +func (h *LetterHandler) BulkArchiveIncomingLetters(c *gin.Context) { + var req contract.BulkArchiveLettersRequest + if !h.bindJSON(c, &req) { + return + } + + if len(req.LetterIDs) == 0 { + h.respondError(c, http.StatusBadRequest, "at least one letter ID is required") + return + } + + resp, err := h.svc.BulkArchiveIncomingLetters(c.Request.Context(), req.LetterIDs) + if err != nil { + h.handleServiceError(c, err) + return + } + + h.respondSuccess(c, http.StatusOK, resp) +} diff --git a/internal/handler/letter_outgoing_handler.go b/internal/handler/letter_outgoing_handler.go index 56cb3ed..ce2740e 100644 --- a/internal/handler/letter_outgoing_handler.go +++ b/internal/handler/letter_outgoing_handler.go @@ -39,6 +39,7 @@ type LetterOutgoingService interface { GetLetterApprovals(ctx context.Context, letterID uuid.UUID) (*contract.GetLetterApprovalsResponse, error) GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error) GetApprovalTimeline(ctx context.Context, letterID uuid.UUID) (*contract.ApprovalTimelineResponse, error) + BulkArchiveOutgoingLetters(ctx context.Context, letterIDs []uuid.UUID) (*contract.BulkArchiveLettersResponse, error) } type LetterOutgoingHandler struct { @@ -479,3 +480,24 @@ func (h *LetterOutgoingHandler) GetApprovalTimeline(c *gin.Context) { c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) } + +func (h *LetterOutgoingHandler) BulkArchiveOutgoingLetters(c *gin.Context) { + var req contract.BulkArchiveLettersRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid request body", Code: http.StatusBadRequest}) + return + } + + if len(req.LetterIDs) == 0 { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "at least one letter ID is required", Code: http.StatusBadRequest}) + return + } + + resp, err := h.svc.BulkArchiveOutgoingLetters(c.Request.Context(), req.LetterIDs) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) + return + } + + c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) +} diff --git a/internal/handler/master_handler_test.go b/internal/handler/master_handler_test.go deleted file mode 100644 index 9f0d3f4..0000000 --- a/internal/handler/master_handler_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package handler - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "eslogad-be/internal/contract" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" -) - -// MockMasterService is a mock implementation of MasterService -type MockMasterService struct { - mock.Mock -} - -func (m *MockMasterService) CreateLabel(ctx context.Context, req *contract.CreateLabelRequest) (*contract.LabelResponse, error) { - args := m.Called(ctx, req) - return args.Get(0).(*contract.LabelResponse), args.Error(1) -} - -func (m *MockMasterService) UpdateLabel(ctx context.Context, id uuid.UUID, req *contract.UpdateLabelRequest) (*contract.LabelResponse, error) { - args := m.Called(ctx, id, req) - return args.Get(0).(*contract.LabelResponse), args.Error(1) -} - -func (m *MockMasterService) DeleteLabel(ctx context.Context, id uuid.UUID) error { - args := m.Called(ctx, id) - return args.Error(0) -} - -func (m *MockMasterService) ListLabels(ctx context.Context) (*contract.ListLabelsResponse, error) { - args := m.Called(ctx) - return args.Get(0).(*contract.ListLabelsResponse), args.Error(1) -} - -func (m *MockMasterService) CreatePriority(ctx context.Context, req *contract.CreatePriorityRequest) (*contract.PriorityResponse, error) { - args := m.Called(ctx, req) - return args.Get(0).(*contract.PriorityResponse), args.Error(1) -} - -func (m *MockMasterService) UpdatePriority(ctx context.Context, id uuid.UUID, req *contract.UpdatePriorityRequest) (*contract.PriorityResponse, error) { - args := m.Called(ctx, id, req) - return args.Get(0).(*contract.PriorityResponse), args.Error(1) -} - -func (m *MockMasterService) DeletePriority(ctx context.Context, id uuid.UUID) error { - args := m.Called(ctx, id) - return args.Error(0) -} - -func (m *MockMasterService) ListPriorities(ctx context.Context) (*contract.ListPrioritiesResponse, error) { - args := m.Called(ctx) - return args.Get(0).(*contract.ListPrioritiesResponse), args.Error(1) -} - -func (m *MockMasterService) CreateInstitution(ctx context.Context, req *contract.CreateInstitutionRequest) (*contract.InstitutionResponse, error) { - args := m.Called(ctx, req) - return args.Get(0).(*contract.InstitutionResponse), args.Error(1) -} - -func (m *MockMasterService) UpdateInstitution(ctx context.Context, id uuid.UUID, req *contract.UpdateInstitutionRequest) (*contract.InstitutionResponse, error) { - args := m.Called(ctx, id, req) - return args.Get(0).(*contract.InstitutionResponse), args.Error(1) -} - -func (m *MockMasterService) DeleteInstitution(ctx context.Context, id uuid.UUID) error { - args := m.Called(ctx, id) - return args.Error(0) -} - -func (m *MockMasterService) ListInstitutions(ctx context.Context, req *contract.ListInstitutionsRequest) (*contract.ListInstitutionsResponse, error) { - args := m.Called(ctx, req) - return args.Get(0).(*contract.ListInstitutionsResponse), args.Error(1) -} - -func (m *MockMasterService) CreateDispositionAction(ctx context.Context, req *contract.CreateDispositionActionRequest) (*contract.DispositionActionResponse, error) { - args := m.Called(ctx, req) - return args.Get(0).(*contract.DispositionActionResponse), args.Error(1) -} - -func (m *MockMasterService) UpdateDispositionAction(ctx context.Context, id uuid.UUID, req *contract.UpdateDispositionActionRequest) (*contract.DispositionActionResponse, error) { - args := m.Called(ctx, id, req) - return args.Get(0).(*contract.DispositionActionResponse), args.Error(1) -} - -func (m *MockMasterService) DeleteDispositionAction(ctx context.Context, id uuid.UUID) error { - args := m.Called(ctx, id) - return args.Error(0) -} - -func (m *MockMasterService) ListDispositionActions(ctx context.Context) (*contract.ListDispositionActionsResponse, error) { - args := m.Called(ctx) - return args.Get(0).(*contract.ListDispositionActionsResponse), args.Error(1) -} - -func TestMasterHandler_ListInstitutions_WithSearch(t *testing.T) { - // Setup - gin.SetMode(gin.TestMode) - mockService := new(MockMasterService) - handler := NewMasterHandler(mockService) - - // Test data - searchTerm := "university" - expectedResponse := &contract.ListInstitutionsResponse{ - Institutions: []contract.InstitutionResponse{ - { - ID: "123", - Name: "Test University", - Type: "university", - }, - }, - } - - // Setup mock expectations - mockService.On("ListInstitutions", mock.Anything, &contract.ListInstitutionsRequest{ - Search: &searchTerm, - }).Return(expectedResponse, nil) - - // Create request - req, _ := http.NewRequest("GET", "/institutions?search="+searchTerm, nil) - w := httptest.NewRecorder() - - // Create gin context - c, _ := gin.CreateTestContext(w) - c.Request = req - - // Execute - handler.ListInstitutions(c) - - // Assertions - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - - // Verify mock was called correctly - mockService.AssertExpectations(t) -} - -func TestMasterHandler_ListInstitutions_WithoutSearch(t *testing.T) { - // Setup - gin.SetMode(gin.TestMode) - mockService := new(MockMasterService) - handler := NewMasterHandler(mockService) - - // Test data - expectedResponse := &contract.ListInstitutionsResponse{ - Institutions: []contract.InstitutionResponse{ - { - ID: "123", - Name: "Test Institution", - Type: "company", - }, - }, - } - - // Setup mock expectations - mockService.On("ListInstitutions", mock.Anything, &contract.ListInstitutionsRequest{ - Search: nil, - }).Return(expectedResponse, nil) - - // Create request - req, _ := http.NewRequest("GET", "/institutions", nil) - w := httptest.NewRecorder() - - // Create gin context - c, _ := gin.CreateTestContext(w) - c.Request = req - - // Execute - handler.ListInstitutions(c) - - // Assertions - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - - // Verify mock was called correctly - mockService.AssertExpectations(t) -} diff --git a/internal/handler/notification_handler.go b/internal/handler/notification_handler.go new file mode 100644 index 0000000..1d25f1e --- /dev/null +++ b/internal/handler/notification_handler.go @@ -0,0 +1,230 @@ +package handler + +import ( + "net/http" + + "eslogad-be/internal/contract" + "eslogad-be/internal/service" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type NotificationHandler struct { + notificationService service.NotificationService +} + +func NewNotificationHandler(notificationService service.NotificationService) *NotificationHandler { + return &NotificationHandler{ + notificationService: notificationService, + } +} + +// TriggerNotification handles single notification trigger +func (h *NotificationHandler) TriggerNotification(c *gin.Context) { + var req contract.TriggerNotificationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + resp, err := h.notificationService.TriggerNotification(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if !resp.Success { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": resp.Message, + }) + return + } + + c.JSON(http.StatusOK, resp) +} + +// BulkTriggerNotification handles bulk notification trigger +func (h *NotificationHandler) BulkTriggerNotification(c *gin.Context) { + var req contract.BulkTriggerNotificationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + resp, err := h.notificationService.BulkTriggerNotification(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, resp) +} + +// GetSubscriber retrieves subscriber information +func (h *NotificationHandler) GetSubscriber(c *gin.Context) { + userIDStr := c.Param("userId") + userID, err := uuid.Parse(userIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"}) + return + } + + resp, err := h.notificationService.GetSubscriber(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, resp) +} + +// UpdateSubscriberChannel updates subscriber channel credentials +func (h *NotificationHandler) UpdateSubscriberChannel(c *gin.Context) { + var req contract.UpdateSubscriberChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + resp, err := h.notificationService.UpdateSubscriberChannel(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if !resp.Success { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": resp.Message, + }) + return + } + + c.JSON(http.StatusOK, resp) +} + +// TriggerNotificationForCurrentUser triggers notification for the authenticated user +func (h *NotificationHandler) TriggerNotificationForCurrentUser(c *gin.Context) { + // Get current user ID from context (set by auth middleware) + userIDInterface, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + userID, ok := userIDInterface.(uuid.UUID) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user ID in context"}) + return + } + + var req struct { + TemplateID string `json:"template_id" validate:"required"` + TemplateData map[string]interface{} `json:"template_data,omitempty"` + Overrides *contract.NotificationOverrides `json:"overrides,omitempty"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Create trigger request with current user ID + triggerReq := &contract.TriggerNotificationRequest{ + UserID: userID, + TemplateID: req.TemplateID, + TemplateData: req.TemplateData, + Overrides: req.Overrides, + } + + resp, err := h.notificationService.TriggerNotification(c.Request.Context(), triggerReq) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if !resp.Success { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": resp.Message, + }) + return + } + + c.JSON(http.StatusOK, resp) +} + +// GetCurrentUserSubscriber retrieves subscriber information for the authenticated user +func (h *NotificationHandler) GetCurrentUserSubscriber(c *gin.Context) { + // Get current user ID from context (set by auth middleware) + userIDInterface, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + userID, ok := userIDInterface.(uuid.UUID) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user ID in context"}) + return + } + + resp, err := h.notificationService.GetSubscriber(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, resp) +} + +// UpdateCurrentUserSubscriberChannel updates channel credentials for the authenticated user +func (h *NotificationHandler) UpdateCurrentUserSubscriberChannel(c *gin.Context) { + // Get current user ID from context (set by auth middleware) + userIDInterface, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + userID, ok := userIDInterface.(uuid.UUID) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user ID in context"}) + return + } + + var req struct { + Channel string `json:"channel" validate:"required,oneof=email sms push chat in_app"` + Credentials map[string]interface{} `json:"credentials" validate:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Create update request with current user ID + updateReq := &contract.UpdateSubscriberChannelRequest{ + UserID: userID, + Channel: req.Channel, + Credentials: req.Credentials, + } + + resp, err := h.notificationService.UpdateSubscriberChannel(c.Request.Context(), updateReq) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if !resp.Success { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": resp.Message, + }) + return + } + + c.JSON(http.StatusOK, resp) +} \ No newline at end of file diff --git a/internal/processor/activity_log_processor.go b/internal/processor/activity_log_processor.go index 0196c5b..f40e6df 100644 --- a/internal/processor/activity_log_processor.go +++ b/internal/processor/activity_log_processor.go @@ -35,3 +35,28 @@ func (p *ActivityLogProcessorImpl) Log(ctx context.Context, letterID uuid.UUID, } return p.repo.Create(ctx, entry) } + +func (p *ActivityLogProcessorImpl) LogLetterDispositionStatusUpdate(ctx context.Context, letterID uuid.UUID, userID uuid.UUID, status string) error { + return p.Log(ctx, letterID, "disposition_status_update", &userID, nil, nil, nil, nil, &status, map[string]interface{}{ + "status": status, + }) +} + +func (p *ActivityLogProcessorImpl) LogLetterCreated(ctx context.Context, letterID uuid.UUID, userID uuid.UUID, letterNumber string) error { + return p.Log(ctx, letterID, "letter.created", &userID, nil, nil, nil, nil, nil, map[string]interface{}{ + "letter_number": letterNumber, + }) +} + +func (p *ActivityLogProcessorImpl) LogAttachmentUploaded(ctx context.Context, letterID uuid.UUID, userID uuid.UUID, fileName string, fileType string) error { + return p.Log(ctx, letterID, "attachment.uploaded", &userID, nil, nil, nil, nil, nil, map[string]interface{}{ + "file_name": fileName, + "file_type": fileType, + }) +} + +func (p *ActivityLogProcessorImpl) LogDispositionCreated(ctx context.Context, letterID uuid.UUID, userID uuid.UUID, departmentCount int) error { + return p.Log(ctx, letterID, "disposition.created", &userID, nil, nil, nil, nil, nil, map[string]interface{}{ + "department_count": departmentCount, + }) +} diff --git a/internal/processor/cached_user_processor.go b/internal/processor/cached_user_processor.go new file mode 100644 index 0000000..a0fa70f --- /dev/null +++ b/internal/processor/cached_user_processor.go @@ -0,0 +1,112 @@ +package processor + +import ( + "context" + "sync" + "time" + + "eslogad-be/internal/contract" + "eslogad-be/internal/repository" + "eslogad-be/internal/transformer" + + "github.com/google/uuid" +) + +// CachedUserProcessor wraps UserProcessor with caching for frequently accessed users +type CachedUserProcessor struct { + userRepo *repository.UserRepositoryImpl + profileRepo *repository.UserProfileRepository + cache map[uuid.UUID]*cacheEntry + mu sync.RWMutex + ttl time.Duration +} + +type cacheEntry struct { + user *contract.UserResponse + expiresAt time.Time +} + +func NewCachedUserProcessor(userRepo *repository.UserRepositoryImpl, profileRepo *repository.UserProfileRepository) *CachedUserProcessor { + return &CachedUserProcessor{ + userRepo: userRepo, + profileRepo: profileRepo, + cache: make(map[uuid.UUID]*cacheEntry), + ttl: 5 * time.Minute, // Cache for 5 minutes + } +} + +func (p *CachedUserProcessor) GetUserByIDCached(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) { + p.mu.RLock() + if entry, exists := p.cache[id]; exists { + if entry.expiresAt.After(time.Now()) { + p.mu.RUnlock() + return entry.user, nil + } + } + p.mu.RUnlock() + + // Not in cache or expired, fetch from database using the light method + user, err := p.userRepo.GetByIDLight(ctx, id) + if err != nil { + return nil, err + } + + // Convert to contract response + resp := &contract.UserResponse{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + IsActive: user.IsActive, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } + + // Store in cache + p.mu.Lock() + p.cache[id] = &cacheEntry{ + user: resp, + expiresAt: time.Now().Add(p.ttl), + } + p.mu.Unlock() + + // Clean expired entries periodically + go p.cleanExpiredEntries() + + return resp, nil +} + +// GetUserByIDFull retrieves full user with all relationships - no caching +func (p *CachedUserProcessor) GetUserByIDFull(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) { + user, err := p.userRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + resp := transformer.EntityToContract(user) + if resp != nil { + if roles, err := p.userRepo.GetRolesByUserID(ctx, resp.ID); err == nil { + resp.Roles = transformer.RolesToContract(roles) + } + } + return resp, nil +} + +// InvalidateCache removes a user from cache +func (p *CachedUserProcessor) InvalidateCache(userID uuid.UUID) { + p.mu.Lock() + delete(p.cache, userID) + p.mu.Unlock() +} + +// cleanExpiredEntries removes expired cache entries +func (p *CachedUserProcessor) cleanExpiredEntries() { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + for id, entry := range p.cache { + if entry.expiresAt.Before(now) { + delete(p.cache, id) + } + } +} diff --git a/internal/processor/cached_user_wrapper.go b/internal/processor/cached_user_wrapper.go new file mode 100644 index 0000000..e5c5044 --- /dev/null +++ b/internal/processor/cached_user_wrapper.go @@ -0,0 +1,33 @@ +package processor + +import ( + "context" + + "eslogad-be/internal/contract" + + "github.com/google/uuid" +) + +// CachedUserWrapper wraps CachedUserProcessor to implement middleware.UserProcessor interface +type CachedUserWrapper struct { + cached *CachedUserProcessor + full *UserProcessorImpl +} + +// NewCachedUserWrapper creates a new wrapper +func NewCachedUserWrapper(cached *CachedUserProcessor, full *UserProcessorImpl) *CachedUserWrapper { + return &CachedUserWrapper{ + cached: cached, + full: full, + } +} + +// GetUserByID uses cached version for fast lookups +func (w *CachedUserWrapper) GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) { + return w.cached.GetUserByIDCached(ctx, id) +} + +// GetUserByIDFull uses full version when all data is needed +func (w *CachedUserWrapper) GetUserByIDFull(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) { + return w.full.GetUserByID(ctx, id) +} \ No newline at end of file diff --git a/internal/processor/letter_disposition_department_processor.go b/internal/processor/letter_disposition_department_processor.go new file mode 100644 index 0000000..96013e4 --- /dev/null +++ b/internal/processor/letter_disposition_department_processor.go @@ -0,0 +1,180 @@ +package processor + +import ( + "context" + "time" + + "eslogad-be/internal/contract" + "eslogad-be/internal/entities" + "eslogad-be/internal/repository" + "eslogad-be/internal/transformer" + + "github.com/google/uuid" +) + +type LetterDispositionDepartmentProcessor interface { + GetByLetterIncomingID(ctx context.Context, letterIncomingID uuid.UUID) ([]entities.LetterIncomingDispositionDepartment, error) + GetDepartmentDispositionStatus(ctx context.Context, letterIncomingID uuid.UUID) (*contract.ListDepartmentDispositionStatusResponse, error) + UpdateDispositionStatus(ctx context.Context, letterIncomingID uuid.UUID, departmentID uuid.UUID, userID uuid.UUID, req *contract.UpdateDispositionStatusRequest) (*contract.DepartmentDispositionStatusResponse, error) + CheckAndUpdateLetterCompletionStatus(ctx context.Context, letterIncomingID uuid.UUID) error +} + +type LetterDispositionDepartmentProcessorImpl struct { + dispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository + dispositionNoteRepo *repository.DispositionNoteRepository + letterRepo *repository.LetterIncomingRepository +} + +func NewLetterDispositionDepartmentProcessor( + dispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository, + dispositionNoteRepo *repository.DispositionNoteRepository, + letterRepo *repository.LetterIncomingRepository, +) *LetterDispositionDepartmentProcessorImpl { + return &LetterDispositionDepartmentProcessorImpl{ + dispositionDeptRepo: dispositionDeptRepo, + dispositionNoteRepo: dispositionNoteRepo, + letterRepo: letterRepo, + } +} + +// GetByLetterIncomingID retrieves all disposition departments for a letter +func (p *LetterDispositionDepartmentProcessorImpl) GetByLetterIncomingID(ctx context.Context, letterIncomingID uuid.UUID) ([]entities.LetterIncomingDispositionDepartment, error) { + return p.dispositionDeptRepo.GetByLetterIncomingID(ctx, letterIncomingID) +} + +// GetDepartmentDispositionStatus retrieves disposition status for a specific letter +func (p *LetterDispositionDepartmentProcessorImpl) GetDepartmentDispositionStatus(ctx context.Context, letterIncomingID uuid.UUID) (*contract.ListDepartmentDispositionStatusResponse, error) { + dispositions, err := p.dispositionDeptRepo.GetByLetterIncomingID(ctx, letterIncomingID) + if err != nil { + return nil, err + } + + response := p.buildDispositionStatusResponse(dispositions) + return response, nil +} + +func (p *LetterDispositionDepartmentProcessorImpl) UpdateDispositionStatus(ctx context.Context, letterIncomingID uuid.UUID, departmentID uuid.UUID, userID uuid.UUID, req *contract.UpdateDispositionStatusRequest) (*contract.DepartmentDispositionStatusResponse, error) { + dispDept, err := p.dispositionDeptRepo.GetByDispositionAndDepartment(ctx, letterIncomingID, departmentID) + if err != nil { + return nil, err + } + + now := time.Now() + var dispositionStatus entities.LetterIncomingDispositionDepartmentStatus + var readAt, completedAt *time.Time + + switch req.Status { + case "completed": + dispositionStatus = entities.DispositionDepartmentStatusCompleted + completedAt = &now + readAt = &now // Mark as read when completing + case "read": + dispositionStatus = entities.DispositionDepartmentStatusRead + readAt = &now + case "dispositioned": + dispositionStatus = entities.DispositionDepartmentStatusDispositioned + default: + dispositionStatus = entities.DispositionDepartmentStatusPending + } + + // Extract notes for the update + notes := "" + if req.Notes != nil && *req.Notes != "" { + notes = *req.Notes + } + + if err := p.dispositionDeptRepo.UpdateStatus(ctx, dispDept.ID, dispositionStatus, notes, readAt, completedAt); err != nil { + return nil, err + } + + // Check and update letter completion status + if err := p.CheckAndUpdateLetterCompletionStatus(ctx, letterIncomingID); err != nil { + // Log error but don't fail the status update + } + + // Get updated record for response + updatedDispDept, err := p.dispositionDeptRepo.GetByID(ctx, dispDept.ID) + if err != nil { + return nil, err + } + + return p.buildSingleDispositionStatusResponse(updatedDispDept), nil +} + +// CheckAndUpdateLetterCompletionStatus checks if all dispositions are completed and updates letter status +func (p *LetterDispositionDepartmentProcessorImpl) CheckAndUpdateLetterCompletionStatus(ctx context.Context, letterIncomingID uuid.UUID) error { + // Get all disposition departments for this letter + dispositions, err := p.dispositionDeptRepo.GetByLetterIncomingID(ctx, letterIncomingID) + if err != nil { + return err + } + + // Check if all dispositions are completed + allCompleted := true + for _, disp := range dispositions { + if disp.Status == entities.DispositionDepartmentStatusPending { + allCompleted = false + break + } + } + + // If all dispositions are completed, update the letter status to completed + if allCompleted && len(dispositions) > 0 { + letter, err := p.letterRepo.GetByID(ctx, letterIncomingID) + if err != nil { + return err + } + + letter.Status = "completed" + if err := p.letterRepo.Update(ctx, letter); err != nil { + return err + } + } + + return nil +} + +// Helper methods + +func (p *LetterDispositionDepartmentProcessorImpl) buildDispositionStatusResponse(dispositions []entities.LetterIncomingDispositionDepartment) *contract.ListDepartmentDispositionStatusResponse { + var response []contract.DepartmentDispositionStatusResponse + + for _, disp := range dispositions { + response = append(response, *p.buildSingleDispositionStatusResponse(&disp)) + } + + return &contract.ListDepartmentDispositionStatusResponse{ + Dispositions: response, + Pagination: contract.PaginationResponse{ + TotalCount: len(response), + Page: 1, + Limit: len(response), + TotalPages: 1, + }, + } +} + +func (p *LetterDispositionDepartmentProcessorImpl) buildSingleDispositionStatusResponse(dispDept *entities.LetterIncomingDispositionDepartment) *contract.DepartmentDispositionStatusResponse { + letterResp := transformer.LetterIncomingEntityToContract(dispDept.LetterIncoming) + + var fromDept *contract.DepartmentResponse + if dispDept.LetterIncomingDisposition != nil && dispDept.LetterIncomingDisposition.DepartmentID != nil { + fromDept = transformer.DepartmentEntityToContract(&dispDept.LetterIncomingDisposition.Department) + } + + return &contract.DepartmentDispositionStatusResponse{ + ID: dispDept.ID, + LetterID: dispDept.LetterIncomingID, + Letter: letterResp, + FromDepartmentID: dispDept.LetterIncomingDisposition.DepartmentID, + FromDepartment: fromDept, + ToDepartmentID: dispDept.DepartmentID, + ToDepartment: transformer.DepartmentEntityToContract(dispDept.Department), + Status: string(dispDept.Status), + Notes: dispDept.LetterIncomingDisposition.Notes, + ReadAt: dispDept.ReadAt, + CompletedAt: dispDept.CompletedAt, + CreatedAt: dispDept.CreatedAt, + UpdatedAt: dispDept.UpdatedAt, + } +} diff --git a/internal/processor/letter_disposition_processor.go b/internal/processor/letter_disposition_processor.go new file mode 100644 index 0000000..d34eb9c --- /dev/null +++ b/internal/processor/letter_disposition_processor.go @@ -0,0 +1,240 @@ +package processor + +import ( + "context" + + "eslogad-be/internal/contract" + "eslogad-be/internal/entities" + "eslogad-be/internal/repository" + "eslogad-be/internal/transformer" + + "github.com/google/uuid" +) + +type LetterDispositionProcessor interface { + CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) + GetByLetterID(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingDisposition, error) + GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) +} + +type LetterDispositionProcessorImpl struct { + dispositionRepo *repository.LetterIncomingDispositionRepository + dispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository + dispositionActionSelRepo *repository.LetterDispositionActionSelectionRepository + dispositionNoteRepo *repository.DispositionNoteRepository + discussionRepo *repository.LetterDiscussionRepository + dispActionRepo *repository.DispositionActionRepository + activity *ActivityLogProcessorImpl +} + +func NewLetterDispositionProcessor( + dispositionRepo *repository.LetterIncomingDispositionRepository, + dispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository, + dispositionActionSelRepo *repository.LetterDispositionActionSelectionRepository, + dispositionNoteRepo *repository.DispositionNoteRepository, + discussionRepo *repository.LetterDiscussionRepository, + dispActionRepo *repository.DispositionActionRepository, + activity *ActivityLogProcessorImpl, +) *LetterDispositionProcessorImpl { + return &LetterDispositionProcessorImpl{ + dispositionRepo: dispositionRepo, + dispositionDeptRepo: dispositionDeptRepo, + dispositionActionSelRepo: dispositionActionSelRepo, + dispositionNoteRepo: dispositionNoteRepo, + discussionRepo: discussionRepo, + dispActionRepo: dispActionRepo, + activity: activity, + } +} + +func (p *LetterDispositionProcessorImpl) CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) { + disposition := &entities.LetterIncomingDisposition{ + LetterID: req.LetterID, + DepartmentID: &req.FromDepartment, + Notes: req.Notes, + CreatedBy: req.CreatedBy, + } + + if err := p.dispositionRepo.Create(ctx, disposition); err != nil { + return nil, err + } + + if err := p.createDispositionDepartments(ctx, disposition.ID, req.LetterID, req.ToDepartmentIDs); err != nil { + return nil, err + } + + if len(req.SelectedActions) > 0 { + if err := p.createActionSelectionsFromRequest(ctx, disposition.ID, req.SelectedActions); err != nil { + return nil, err + } + } + + if p.activity != nil { + p.activity.LogDispositionCreated(ctx, req.LetterID, req.CreatedBy, len(req.ToDepartmentIDs)) + } + + // Build response + dispositions := []entities.LetterIncomingDisposition{*disposition} + response := p.buildDispositionsResponse(dispositions) + return response, nil +} + +func (p *LetterDispositionProcessorImpl) GetByLetterID(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingDisposition, error) { + return p.dispositionRepo.ListByLetter(ctx, letterID) +} + +func (p *LetterDispositionProcessorImpl) GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) { + // Get dispositions + dispositions, err := p.dispositionRepo.ListByLetter(ctx, letterID) + if err != nil { + return nil, err + } + + // Get discussions + discussions, err := p.discussionRepo.ListByLetter(ctx, letterID) + if err != nil { + return nil, err + } + + // Build enhanced response + enhancedDispositions := make([]contract.EnhancedDispositionResponse, 0, len(dispositions)) + for _, disp := range dispositions { + // Build disposition response using existing structure + var dept contract.DepartmentResponse + if disp.Department.ID != uuid.Nil { + dept = *transformer.DepartmentEntityToContract(&disp.Department) + } + + // Build departments + departments := make([]contract.DispositionDepartmentResponse, 0, len(disp.Departments)) + for _, d := range disp.Departments { + departments = append(departments, contract.DispositionDepartmentResponse{ + ID: d.ID, + DepartmentID: d.DepartmentID, + Department: transformer.DepartmentEntityToContract(d.Department), + }) + } + + // Build actions + actions := make([]contract.DispositionActionSelectionResponse, 0, len(disp.ActionSelections)) + for _, a := range disp.ActionSelections { + if a.Action != nil { + actions = append(actions, contract.DispositionActionSelectionResponse{ + ID: a.ID, + ActionID: a.ActionID, + Action: &contract.DispositionActionResponse{ + ID: a.Action.ID.String(), + Code: a.Action.Code, + Label: a.Action.Label, + Description: a.Action.Description, + RequiresNote: a.Action.RequiresNote, + }, + }) + } + } + + // Build notes + notes := make([]contract.DispositionNoteResponse, 0, len(disp.DispositionNotes)) + for _, n := range disp.DispositionNotes { + var userResp *contract.UserResponse + if n.User != nil { + userResp = transformer.EntityToContract(n.User) + } + notes = append(notes, contract.DispositionNoteResponse{ + ID: n.ID, + Note: n.Note, + CreatedAt: n.CreatedAt, + User: userResp, + }) + } + + enhancedDispositions = append(enhancedDispositions, contract.EnhancedDispositionResponse{ + ID: disp.ID, + LetterID: disp.LetterID, + DepartmentID: disp.DepartmentID, + Notes: disp.Notes, + ReadAt: disp.ReadAt, + CreatedBy: disp.CreatedBy, + CreatedAt: disp.CreatedAt, + UpdatedAt: disp.UpdatedAt, + Department: dept, + Departments: departments, + Actions: actions, + DispositionNotes: notes, + }) + } + + // Get general discussions + var generalDiscussions []contract.LetterDiscussionResponse + for _, disc := range discussions { + generalDiscussions = append(generalDiscussions, *transformer.DiscussionEntityToContract(&disc)) + } + + return &contract.ListEnhancedDispositionsResponse{ + Dispositions: enhancedDispositions, + Discussions: generalDiscussions, + }, nil +} + +// Helper methods + +func (p *LetterDispositionProcessorImpl) createDispositionDepartments(ctx context.Context, dispositionID, letterID uuid.UUID, departmentIDs []uuid.UUID) error { + departments := make([]entities.LetterIncomingDispositionDepartment, 0, len(departmentIDs)) + + for _, deptID := range departmentIDs { + departments = append(departments, entities.LetterIncomingDispositionDepartment{ + LetterIncomingDispositionID: dispositionID, + LetterIncomingID: letterID, + DepartmentID: deptID, + Status: entities.DispositionDepartmentStatusPending, + }) + } + + if len(departments) > 0 { + return p.dispositionDeptRepo.CreateBulk(ctx, departments) + } + + return nil +} + +func (p *LetterDispositionProcessorImpl) createActionSelectionsFromRequest(ctx context.Context, dispositionID uuid.UUID, selectedActions []contract.CreateDispositionActionSelection) error { + selections := make([]entities.LetterDispositionActionSelection, 0, len(selectedActions)) + + for _, action := range selectedActions { + selections = append(selections, entities.LetterDispositionActionSelection{ + DispositionID: dispositionID, + ActionID: action.ActionID, + }) + } + + if len(selections) > 0 { + return p.dispositionActionSelRepo.CreateBulk(ctx, selections) + } + + return nil +} + +func (p *LetterDispositionProcessorImpl) buildDispositionsResponse(dispositions []entities.LetterIncomingDisposition) *contract.ListDispositionsResponse { + dispositionResponses := make([]contract.DispositionResponse, 0, len(dispositions)) + + for _, disp := range dispositions { + dispositionResponses = append(dispositionResponses, *p.buildDispositionResponse(&disp)) + } + + return &contract.ListDispositionsResponse{ + Dispositions: dispositionResponses, + } +} + +func (p *LetterDispositionProcessorImpl) buildDispositionResponse(disp *entities.LetterIncomingDisposition) *contract.DispositionResponse { + return &contract.DispositionResponse{ + ID: disp.ID, + LetterID: disp.LetterID, + DepartmentID: disp.DepartmentID, + Notes: disp.Notes, + ReadAt: disp.ReadAt, + CreatedBy: disp.CreatedBy, + CreatedAt: disp.CreatedAt, + UpdatedAt: disp.UpdatedAt, + } +} diff --git a/internal/processor/letter_outgoing_processor.go b/internal/processor/letter_outgoing_processor.go index bc2ebbe..9f6d470 100644 --- a/internal/processor/letter_outgoing_processor.go +++ b/internal/processor/letter_outgoing_processor.go @@ -43,6 +43,13 @@ type LetterOutgoingProcessor interface { // GetOutgoingLetterWithDetails fetches letter with all related data GetOutgoingLetterWithDetails(ctx context.Context, letterID uuid.UUID) (*entities.LetterOutgoing, error) GetUsersByIDs(ctx context.Context, userIDs []uuid.UUID) ([]entities.User, error) + BulkArchiveOutgoingLetters(ctx context.Context, letterIDs []uuid.UUID) (int64, error) + + // Batch loading methods for efficient querying + GetBatchAttachments(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterOutgoingAttachment, error) + GetBatchRecipients(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterOutgoingRecipient, error) + GetBatchPriorities(ctx context.Context, priorityIDs []uuid.UUID) (map[uuid.UUID]*entities.Priority, error) + GetBatchInstitutions(ctx context.Context, institutionIDs []uuid.UUID) (map[uuid.UUID]*entities.Institution, error) } type LetterOutgoingProcessorImpl struct { @@ -57,6 +64,8 @@ type LetterOutgoingProcessorImpl struct { approvalRepo *repository.LetterOutgoingApprovalRepository numberGenerator *LetterNumberGeneratorImpl txManager *repository.TxManager + priorityRepo *repository.PriorityRepository + institutionRepo *repository.InstitutionRepository } func NewLetterOutgoingProcessor( @@ -71,6 +80,8 @@ func NewLetterOutgoingProcessor( approvalRepo *repository.LetterOutgoingApprovalRepository, numberGenerator *LetterNumberGeneratorImpl, txManager *repository.TxManager, + priorityRepo *repository.PriorityRepository, + institutionRepo *repository.InstitutionRepository, ) *LetterOutgoingProcessorImpl { return &LetterOutgoingProcessorImpl{ db: db, @@ -84,6 +95,8 @@ func NewLetterOutgoingProcessor( approvalRepo: approvalRepo, numberGenerator: numberGenerator, txManager: txManager, + priorityRepo: priorityRepo, + institutionRepo: institutionRepo, } } @@ -766,3 +779,39 @@ func (p *LetterOutgoingProcessorImpl) GetUsersByIDs(ctx context.Context, userIDs return users, nil } + +func (p *LetterOutgoingProcessorImpl) BulkArchiveOutgoingLetters(ctx context.Context, letterIDs []uuid.UUID) (int64, error) { + return p.letterRepo.BulkArchive(ctx, letterIDs) +} + +// GetBatchAttachments fetches attachments for multiple letters in a single query +func (p *LetterOutgoingProcessorImpl) GetBatchAttachments(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterOutgoingAttachment, error) { + if p.attachmentRepo == nil || len(letterIDs) == 0 { + return make(map[uuid.UUID][]entities.LetterOutgoingAttachment), nil + } + return p.attachmentRepo.ListByLetterIDs(ctx, letterIDs) +} + +// GetBatchRecipients fetches recipients for multiple letters in a single query +func (p *LetterOutgoingProcessorImpl) GetBatchRecipients(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterOutgoingRecipient, error) { + if p.recipientRepo == nil || len(letterIDs) == 0 { + return make(map[uuid.UUID][]entities.LetterOutgoingRecipient), nil + } + return p.recipientRepo.ListByLetterIDs(ctx, letterIDs) +} + +// GetBatchPriorities fetches priorities by IDs in a single query +func (p *LetterOutgoingProcessorImpl) GetBatchPriorities(ctx context.Context, priorityIDs []uuid.UUID) (map[uuid.UUID]*entities.Priority, error) { + if p.priorityRepo == nil || len(priorityIDs) == 0 { + return make(map[uuid.UUID]*entities.Priority), nil + } + return p.priorityRepo.GetByIDs(ctx, priorityIDs) +} + +// GetBatchInstitutions fetches institutions by IDs in a single query +func (p *LetterOutgoingProcessorImpl) GetBatchInstitutions(ctx context.Context, institutionIDs []uuid.UUID) (map[uuid.UUID]*entities.Institution, error) { + if p.institutionRepo == nil || len(institutionIDs) == 0 { + return make(map[uuid.UUID]*entities.Institution), nil + } + return p.institutionRepo.GetByIDs(ctx, institutionIDs) +} diff --git a/internal/processor/letter_processor.go b/internal/processor/letter_processor.go index 8493659..ba90e64 100644 --- a/internal/processor/letter_processor.go +++ b/internal/processor/letter_processor.go @@ -25,141 +25,68 @@ type LetterProcessorImpl struct { discussionRepo *repository.LetterDiscussionRepository settingRepo *repository.AppSettingRepository recipientRepo *repository.LetterIncomingRecipientRepository + outgoingRecipientRepo *repository.LetterOutgoingRecipientRepository departmentRepo *repository.DepartmentRepository userDeptRepo *repository.UserDepartmentRepository priorityRepo *repository.PriorityRepository institutionRepo *repository.InstitutionRepository dispActionRepo *repository.DispositionActionRepository + dispoRoutes *repository.DispositionRouteRepository numberGenerator *LetterNumberGeneratorImpl } -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, numberGenerator *LetterNumberGeneratorImpl) *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, numberGenerator: numberGenerator} +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, + outgoingRecipientRepo *repository.LetterOutgoingRecipientRepository, + departmentRepo *repository.DepartmentRepository, + userDeptRepo *repository.UserDepartmentRepository, + priorityRepo *repository.PriorityRepository, + institutionRepo *repository.InstitutionRepository, + dispActionRepo *repository.DispositionActionRepository, + numberGenerator *LetterNumberGeneratorImpl, + dispoRoutes *repository.DispositionRouteRepository) *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, + outgoingRecipientRepo: outgoingRecipientRepo, + departmentRepo: departmentRepo, userDeptRepo: userDeptRepo, priorityRepo: priorityRepo, + institutionRepo: institutionRepo, dispActionRepo: dispActionRepo, numberGenerator: numberGenerator, + dispoRoutes: dispoRoutes} } func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) { - var result *contract.IncomingLetterResponse - err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - userID := appcontext.FromGinContext(txCtx).UserID + userID := appcontext.FromGinContext(ctx).UserID - letterNumber, err := p.numberGenerator.GenerateNumber( - txCtx, - contract.SettingIncomingLetterPrefix, - contract.SettingIncomingLetterSequence, - "ESLI", - ) - if err != nil { - return err - } + entity := &entities.LetterIncoming{ + LetterNumber: req.LetterNumber, + ReferenceNumber: req.ReferenceNumber, + Subject: req.Subject, + Description: req.Description, + PriorityID: req.PriorityID, + SenderInstitutionID: req.SenderInstitutionID, + ReceivedDate: req.ReceivedDate, + DueDate: req.DueDate, + Status: entities.LetterIncomingStatusNew, + CreatedBy: userID, + } - entity := &entities.LetterIncoming{ - ReferenceNumber: req.ReferenceNumber, - Subject: req.Subject, - Description: req.Description, - PriorityID: req.PriorityID, - SenderInstitutionID: req.SenderInstitutionID, - ReceivedDate: req.ReceivedDate, - DueDate: req.DueDate, - Status: entities.LetterIncomingStatusNew, - CreatedBy: userID, - } - entity.LetterNumber = letterNumber - if err := p.letterRepo.Create(txCtx, entity); err != nil { - return err - } - - defaultDeptCodes := []string{} - if s, err := p.settingRepo.Get(txCtx, contract.SettingIncomingLetterRecipients); err == nil { - if arr, ok := s.Value["department_codes"].([]interface{}); ok { - for _, it := range arr { - if str, ok := it.(string); ok { - defaultDeptCodes = append(defaultDeptCodes, str) - } - } - } - } - - depIDs := make([]uuid.UUID, 0, len(defaultDeptCodes)) - for _, code := range defaultDeptCodes { - dep, err := p.departmentRepo.GetByCode(txCtx, code) - if err != nil { - continue - } - depIDs = append(depIDs, dep.ID) - } - - userMemberships, _ := p.userDeptRepo.ListActiveByDepartmentIDs(txCtx, depIDs) - var recipients []entities.LetterIncomingRecipient - - mapsUsers := map[string]bool{} - for _, row := range userMemberships { - uid := row.UserID - if _, ok := mapsUsers[uid.String()]; !ok { - recipients = append(recipients, entities.LetterIncomingRecipient{LetterID: entity.ID, RecipientUserID: &uid, RecipientDepartmentID: &row.DepartmentID, Status: entities.RecipientStatusNew}) - } - mapsUsers[uid.String()] = true - } - - if len(recipients) > 0 { - if err := p.recipientRepo.CreateBulk(txCtx, recipients); err != nil { - return err - } - } - - if p.activity != nil { - action := "letter.created" - if err := p.activity.Log(txCtx, entity.ID, action, &userID, nil, nil, nil, nil, nil, map[string]interface{}{"letter_number": letterNumber}); err != nil { - return err - } - } - - attachments := make([]entities.LetterIncomingAttachment, 0, len(req.Attachments)) - for _, a := range req.Attachments { - attachments = append(attachments, entities.LetterIncomingAttachment{LetterID: entity.ID, FileURL: a.FileURL, FileName: a.FileName, FileType: a.FileType, UploadedBy: &userID}) - } - if len(attachments) > 0 { - if err := p.attachRepo.CreateBulk(txCtx, attachments); err != nil { - return err - } - if p.activity != nil { - action := "attachment.uploaded" - for _, a := range attachments { - ctxMap := map[string]interface{}{"file_name": a.FileName, "file_type": a.FileType} - if err := p.activity.Log(txCtx, entity.ID, action, &userID, nil, nil, nil, nil, nil, ctxMap); err != nil { - return err - } - } - } - } - - savedAttachments, _ := p.attachRepo.ListByLetter(txCtx, entity.ID) - var pr *entities.Priority - if entity.PriorityID != nil { - if p.priorityRepo != nil { - if got, err := p.priorityRepo.Get(txCtx, *entity.PriorityID); err == nil { - pr = got - } - } - } - var inst *entities.Institution - if entity.SenderInstitutionID != nil { - if p.institutionRepo != nil { - if got, err := p.institutionRepo.Get(txCtx, *entity.SenderInstitutionID); err == nil { - inst = got - } - } - } - result = transformer.LetterEntityToContract(entity, savedAttachments, pr, inst) - return nil - }) - - if err != nil { + if err := p.letterRepo.Create(ctx, entity); err != nil { return nil, err } - return result, nil + + if err := p.createAttachments(ctx, entity.ID, req.Attachments, userID); err != nil { + return nil, err + } + + return p.buildLetterResponse(ctx, entity) } func (p *LetterProcessorImpl) GetIncomingLetterByID(ctx context.Context, id uuid.UUID) (*contract.IncomingLetterResponse, error) { + // Get current user ID from context + userID := appcontext.FromGinContext(ctx).UserID + entity, err := p.letterRepo.Get(ctx, id) if err != nil { return nil, err @@ -177,45 +104,128 @@ func (p *LetterProcessorImpl) GetIncomingLetterByID(ctx context.Context, id uuid inst = got } } - return transformer.LetterEntityToContract(entity, atts, pr, inst), nil + + // Check if letter is read by current user + isRead := false + if p.recipientRepo != nil { + if recipient, err := p.recipientRepo.GetByLetterAndUser(ctx, id, userID); err == nil { + isRead = recipient.ReadAt != nil + } + } + + resp := transformer.LetterEntityToContract(entity, atts, pr, inst) + resp.IsRead = isRead + + // Include created_by if the current user is the creator + if entity.CreatedBy == userID { + resp.CreatedBy = entity.CreatedBy + } + + return resp, nil } -func (p *LetterProcessorImpl) ListIncomingLetters(ctx context.Context, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error) { - page, limit := req.Page, req.Limit +func (p *LetterProcessorImpl) GetLetterUnreadCounts(ctx context.Context) (*contract.LetterUnreadCountResponse, error) { + userID := appcontext.FromGinContext(ctx).UserID - filter := repository.ListIncomingLettersFilter{ - Status: req.Status, - Query: req.Query, - DepartmentID: req.DepartmentID, - } - - list, total, err := p.letterRepo.List(ctx, filter, limit, (page-1)*limit) - if err != nil { - return nil, err - } - - respList := make([]contract.IncomingLetterResponse, 0, len(list)) - - for _, e := range list { - atts, _ := p.attachRepo.ListByLetter(ctx, e.ID) - var pr *entities.Priority - if e.PriorityID != nil && p.priorityRepo != nil { - if got, err := p.priorityRepo.Get(ctx, *e.PriorityID); err == nil { - pr = got - } + incomingUnread := 0 + if p.recipientRepo != nil { + if count, err := p.recipientRepo.CountUnreadByUser(ctx, userID); err == nil { + incomingUnread = count } - - 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 + + outgoingUnread := 0 + if p.outgoingRecipientRepo != nil { + if count, err := p.outgoingRecipientRepo.CountUnreadByUser(ctx, userID); err == nil { + outgoingUnread = count + } + } + + response := &contract.LetterUnreadCountResponse{} + response.IncomingLetter.Unread = incomingUnread + response.OutgoingLetter.Unread = outgoingUnread + + return response, nil +} + +func (p *LetterProcessorImpl) MarkIncomingLetterAsRead(ctx context.Context, letterID uuid.UUID) (*contract.MarkLetterReadResponse, error) { + // Get current user ID from context + userID := appcontext.FromGinContext(ctx).UserID + + // Mark the letter as read for the current user + if p.recipientRepo != nil { + if err := p.recipientRepo.MarkAsRead(ctx, letterID, userID); err != nil { + return &contract.MarkLetterReadResponse{ + Success: false, + Message: "Failed to mark letter as read", + }, err + } + } + + return &contract.MarkLetterReadResponse{ + Success: true, + Message: "Letter marked as read successfully", + }, nil +} + +func (p *LetterProcessorImpl) MarkOutgoingLetterAsRead(ctx context.Context, letterID uuid.UUID) (*contract.MarkLetterReadResponse, error) { + // Get current user ID from context + userID := appcontext.FromGinContext(ctx).UserID + + // Mark the letter as read for the current user + if p.outgoingRecipientRepo != nil { + if err := p.outgoingRecipientRepo.MarkAsRead(ctx, letterID, userID); err != nil { + return &contract.MarkLetterReadResponse{ + Success: false, + Message: "Failed to mark letter as read", + }, err + } + } + + return &contract.MarkLetterReadResponse{ + Success: true, + Message: "Letter marked as read successfully", + }, nil +} + +func (p *LetterProcessorImpl) ListIncomingLetters(ctx context.Context, filter repository.ListIncomingLettersFilter, page, limit int) ([]entities.LetterIncoming, int64, error) { + // Just fetch the raw data + return p.letterRepo.List(ctx, filter, limit, (page-1)*limit) +} + +func (p *LetterProcessorImpl) GetBatchAttachments(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterIncomingAttachment, error) { + if p.attachRepo == nil || len(letterIDs) == 0 { + return make(map[uuid.UUID][]entities.LetterIncomingAttachment), nil + } + return p.attachRepo.ListByLetterIDs(ctx, letterIDs) +} + +func (p *LetterProcessorImpl) GetBatchPriorities(ctx context.Context, priorityIDs []uuid.UUID) (map[uuid.UUID]*entities.Priority, error) { + if p.priorityRepo == nil || len(priorityIDs) == 0 { + return make(map[uuid.UUID]*entities.Priority), nil + } + return p.priorityRepo.GetByIDs(ctx, priorityIDs) +} + +func (p *LetterProcessorImpl) GetBatchInstitutions(ctx context.Context, institutionIDs []uuid.UUID) (map[uuid.UUID]*entities.Institution, error) { + if p.institutionRepo == nil || len(institutionIDs) == 0 { + return make(map[uuid.UUID]*entities.Institution), nil + } + return p.institutionRepo.GetByIDs(ctx, institutionIDs) +} + +func (p *LetterProcessorImpl) GetBatchRecipientsByUser(ctx context.Context, letterIDs []uuid.UUID, userID uuid.UUID) (map[uuid.UUID]*entities.LetterIncomingRecipient, error) { + if p.recipientRepo == nil || len(letterIDs) == 0 { + return make(map[uuid.UUID]*entities.LetterIncomingRecipient), nil + } + return p.recipientRepo.GetByLetterIDsAndUser(ctx, letterIDs, userID) +} + +func (p *LetterProcessorImpl) CountUnreadByUser(ctx context.Context, userID uuid.UUID) (int, error) { + if p.recipientRepo == nil { + return 0, nil + } + return p.recipientRepo.CountUnreadByUser(ctx, userID) } func (p *LetterProcessorImpl) UpdateIncomingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) { @@ -304,6 +314,18 @@ func (p *LetterProcessorImpl) CreateDispositions(ctx context.Context, req *contr err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { userID := appcontext.FromGinContext(txCtx).UserID + existingDispDepts, err := p.dispositionDeptRepo.GetByLetterAndDepartment(txCtx, req.LetterID, req.FromDepartment) + if err == nil && len(existingDispDepts) > 0 { + for _, existingDispDept := range existingDispDepts { + if existingDispDept.Status == entities.DispositionDepartmentStatusPending { + existingDispDept.Status = entities.DispositionDepartmentStatusDispositioned + if err := p.dispositionDeptRepo.Update(txCtx, &existingDispDept); err != nil { + return err + } + } + } + } + disp := entities.LetterIncomingDisposition{ LetterID: req.LetterID, DepartmentID: &req.FromDepartment, @@ -318,7 +340,9 @@ func (p *LetterProcessorImpl) CreateDispositions(ctx context.Context, req *contr for _, toDept := range req.ToDepartmentIDs { dispDepartments = append(dispDepartments, entities.LetterIncomingDispositionDepartment{ LetterIncomingDispositionID: disp.ID, + LetterIncomingID: req.LetterID, DepartmentID: toDept, + Status: entities.DispositionDepartmentStatusPending, }) } @@ -487,3 +511,57 @@ func (p *LetterProcessorImpl) UpdateDiscussion(ctx context.Context, letterID uui } return out, nil } + +func (p *LetterProcessorImpl) createAttachments(ctx context.Context, letterID uuid.UUID, attachments []contract.CreateIncomingLetterAttachment, userID uuid.UUID) error { + if len(attachments) == 0 { + return nil + } + + attachmentEntities := make([]entities.LetterIncomingAttachment, 0, len(attachments)) + for _, a := range attachments { + attachmentEntities = append(attachmentEntities, entities.LetterIncomingAttachment{ + LetterID: letterID, + FileURL: a.FileURL, + FileName: a.FileName, + FileType: a.FileType, + UploadedBy: &userID, + }) + } + + if err := p.attachRepo.CreateBulk(ctx, attachmentEntities); err != nil { + return err + } + + // Attachment logging will be handled by service layer + + return nil +} + +func (p *LetterProcessorImpl) buildLetterResponse(ctx context.Context, entity *entities.LetterIncoming) (*contract.IncomingLetterResponse, error) { + savedAttachments, _ := p.attachRepo.ListByLetter(ctx, entity.ID) + + var pr *entities.Priority + if entity.PriorityID != nil && p.priorityRepo != nil { + if got, err := p.priorityRepo.Get(ctx, *entity.PriorityID); err == nil { + pr = got + } + } + + var inst *entities.Institution + if entity.SenderInstitutionID != nil && p.institutionRepo != nil { + if got, err := p.institutionRepo.Get(ctx, *entity.SenderInstitutionID); err == nil { + inst = got + } + } + + return transformer.LetterEntityToContract(entity, savedAttachments, pr, inst), nil +} + +func (p *LetterProcessorImpl) BulkArchiveIncomingLetters(ctx context.Context, letterIDs []uuid.UUID) (int64, error) { + return p.letterRepo.BulkArchive(ctx, letterIDs) +} + +// BulkArchiveIncomingLettersForUser archives letters for a specific user only +func (p *LetterProcessorImpl) BulkArchiveIncomingLettersForUser(ctx context.Context, letterIDs []uuid.UUID, userID uuid.UUID) (int64, error) { + return p.letterRepo.BulkArchiveForUser(ctx, letterIDs, userID) +} diff --git a/internal/processor/letter_processor_status.go b/internal/processor/letter_processor_status.go new file mode 100644 index 0000000..a838101 --- /dev/null +++ b/internal/processor/letter_processor_status.go @@ -0,0 +1,251 @@ +package processor + +import ( + "context" + "fmt" + "time" + + "eslogad-be/internal/appcontext" + "eslogad-be/internal/contract" + "eslogad-be/internal/entities" + "eslogad-be/internal/transformer" + + "github.com/google/uuid" +) + +func (p *LetterProcessorImpl) GetDepartmentDispositionStatus(ctx context.Context, req *contract.GetDepartmentDispositionStatusRequest) (*contract.ListDepartmentDispositionStatusResponse, error) { + dispositions, err := p.dispositionDeptRepo.GetByLetterIncomingID(ctx, req.LetterIncomingID) + if err != nil { + return nil, err + } + + var response []contract.DepartmentDispositionStatusResponse + for _, disp := range dispositions { + letterResp := transformer.LetterIncomingEntityToContract(disp.LetterIncoming) + + var fromDept *contract.DepartmentResponse + if disp.LetterIncomingDisposition != nil && disp.LetterIncomingDisposition.DepartmentID != nil { + fromDept = transformer.DepartmentEntityToContract(&disp.LetterIncomingDisposition.Department) + } + + response = append(response, contract.DepartmentDispositionStatusResponse{ + ID: disp.ID, + LetterID: disp.LetterIncomingID, + Letter: letterResp, + FromDepartmentID: disp.LetterIncomingDisposition.DepartmentID, + FromDepartment: fromDept, + ToDepartmentID: disp.DepartmentID, + ToDepartment: transformer.DepartmentEntityToContract(disp.Department), + Status: string(disp.Status), + Notes: disp.LetterIncomingDisposition.Notes, + ReadAt: disp.ReadAt, + CompletedAt: disp.CompletedAt, + CreatedAt: disp.CreatedAt, + UpdatedAt: disp.UpdatedAt, + }) + } + + return &contract.ListDepartmentDispositionStatusResponse{ + Dispositions: response, + Pagination: contract.PaginationResponse{ + TotalCount: len(response), + Page: 1, + Limit: len(response), + TotalPages: 1, + }, + }, nil +} + +func (p *LetterProcessorImpl) UpdateDispositionStatus(ctx context.Context, req *contract.UpdateDispositionStatusRequest) (*contract.DepartmentDispositionStatusResponse, error) { + var result *contract.DepartmentDispositionStatusResponse + + err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { + userID := appcontext.FromGinContext(txCtx).UserID + departmentID := appcontext.FromGinContext(txCtx).DepartmentID + + dispDept, err := p.dispositionDeptRepo.GetByDispositionAndDepartment(txCtx, req.LetterIncomingID, departmentID) + if err != nil { + return err + } + + notes := "" + if req.Notes != nil { + notes = *req.Notes + } + if err := p.updateDispositionDepartmentStatus(txCtx, dispDept.ID, req.Status, notes); err != nil { + return err + } + + p.activity.LogLetterDispositionStatusUpdate(txCtx, req.LetterIncomingID, userID, req.Status) + + if err := p.checkAndUpdateLetterCompletionStatus(txCtx, req.LetterIncomingID); err != nil { + return err + } + + updatedDispDept, err := p.dispositionDeptRepo.GetByID(txCtx, dispDept.ID) + if err != nil { + return err + } + + result = p.buildDispositionStatusResponse(updatedDispDept) + return nil + }) + + if err != nil { + return nil, err + } + + return result, nil +} + +// updateDispositionDepartmentStatus updates the status of a disposition department +func (p *LetterProcessorImpl) updateDispositionDepartmentStatus(ctx context.Context, dispDeptID uuid.UUID, status, notes string) error { + now := time.Now() + var dispositionStatus entities.LetterIncomingDispositionDepartmentStatus + var readAt, completedAt *time.Time + + switch status { + case "completed": + dispositionStatus = entities.DispositionDepartmentStatusCompleted + completedAt = &now + readAt = &now // Mark as read when completing + case "read": + dispositionStatus = entities.DispositionDepartmentStatusRead + readAt = &now + default: + dispositionStatus = entities.DispositionDepartmentStatusPending + } + + return p.dispositionDeptRepo.UpdateStatus(ctx, dispDeptID, dispositionStatus, notes, readAt, completedAt) +} + +// addDispositionNoteIfProvided adds a note to the disposition if provided +func (p *LetterProcessorImpl) addDispositionNoteIfProvided(ctx context.Context, dispositionID uuid.UUID, userID uuid.UUID, notes *string) error { + if notes == nil || *notes == "" { + return nil + } + + note := &entities.DispositionNote{ + DispositionID: dispositionID, + UserID: &userID, + Note: *notes, + } + + return p.dispositionNoteRepo.Create(ctx, note) +} + +func (p *LetterProcessorImpl) checkAndUpdateLetterCompletionStatus(ctx context.Context, letterIncomingID uuid.UUID) error { + dispositions, err := p.dispositionDeptRepo.GetByLetterIncomingID(ctx, letterIncomingID) + if err != nil { + return err + } + + allCompleted := true + for _, disp := range dispositions { + if disp.Status == entities.DispositionDepartmentStatusPending { + allCompleted = false + break + } + } + + if allCompleted && len(dispositions) > 0 { + letter, err := p.letterRepo.GetByID(ctx, letterIncomingID) + if err != nil { + return err + } + + letter.Status = "completed" + if err := p.letterRepo.Update(ctx, letter); err != nil { + return err + } + } + + return nil +} + +// buildDispositionStatusResponse builds the response for disposition status +func (p *LetterProcessorImpl) buildDispositionStatusResponse(dispDept *entities.LetterIncomingDispositionDepartment) *contract.DepartmentDispositionStatusResponse { + letterResp := transformer.LetterIncomingEntityToContract(dispDept.LetterIncoming) + + var fromDept *contract.DepartmentResponse + if dispDept.LetterIncomingDisposition != nil && dispDept.LetterIncomingDisposition.DepartmentID != nil { + fromDept = transformer.DepartmentEntityToContract(&dispDept.LetterIncomingDisposition.Department) + } + + return &contract.DepartmentDispositionStatusResponse{ + ID: dispDept.ID, + LetterID: dispDept.LetterIncomingID, + Letter: letterResp, + FromDepartmentID: dispDept.LetterIncomingDisposition.DepartmentID, + FromDepartment: fromDept, + ToDepartmentID: dispDept.DepartmentID, + ToDepartment: transformer.DepartmentEntityToContract(dispDept.Department), + Status: string(dispDept.Status), + Notes: dispDept.LetterIncomingDisposition.Notes, + ReadAt: dispDept.ReadAt, + CompletedAt: dispDept.CompletedAt, + CreatedAt: dispDept.CreatedAt, + UpdatedAt: dispDept.UpdatedAt, + } +} + +func (p *LetterProcessorImpl) GetLetterCTA(ctx context.Context, letterIncomingID uuid.UUID, departmentID uuid.UUID) (*contract.LetterCTAResponse, error) { + letter, err := p.letterRepo.GetByID(ctx, letterIncomingID) + if err != nil { + return nil, err + } + + response := &contract.LetterCTAResponse{ + LetterIncomingID: letterIncomingID, + Actions: []contract.LetterCTAAction{}, + Message: "", + } + + isEligibleForDispo, err := p.dispoRoutes.IsEligibleForDisposition(ctx, departmentID) + if err != nil { + return nil, err + } + + if letter.Status == "completed" || letter.Status == "archived" { + response.Message = "Letter is no longer accepting actions" + return response, nil + } + + dispDepts, err := p.dispositionDeptRepo.GetByLetterAndDepartment(ctx, letterIncomingID, departmentID) + if err != nil { + return nil, err + } + + if len(dispDepts) == 0 { + response.Message = "Your department is not a recipient of this letter" + return response, nil + } + + for _, dispDept := range dispDepts { + if dispDept.Status == entities.DispositionDepartmentStatusPending { + response.DispositionID = &dispDept.LetterIncomingDispositionID + currentStatus := string(dispDept.Status) + response.CurrentStatus = ¤tStatus + + if isEligibleForDispo { + response.Actions = append(response.Actions, contract.LetterCTAAction{ + Type: "create_disposition", + Label: "Disposisi", + Path: fmt.Sprintf("/api/v1/letters/%s/dispositions", letterIncomingID), + Method: "POST", + Description: "Create a new disposition for this letter", + }) + } + + response.Actions = append(response.Actions, contract.LetterCTAAction{ + Type: "update_status", + Label: "Tindak Lanjut", + Path: fmt.Sprintf("/api/v1/letters/dispositions/%s/status", response.LetterIncomingID), + Method: "PUT", + Description: "Update the status of your disposition", + }) + } + } + + return response, nil +} diff --git a/internal/processor/notification_processor.go b/internal/processor/notification_processor.go new file mode 100644 index 0000000..ef81747 --- /dev/null +++ b/internal/processor/notification_processor.go @@ -0,0 +1,361 @@ +package processor + +import ( + "context" + "fmt" + "net/url" + + "eslogad-be/internal/config" + "eslogad-be/internal/contract" + "eslogad-be/internal/entities" + + "github.com/google/uuid" + novu "github.com/novuhq/go-novu/lib" +) + +type NotificationProcessor interface { + // User management + CreateSubscriber(ctx context.Context, user *entities.User) error + UpdateSubscriber(ctx context.Context, user *entities.User) error + DeleteSubscriber(ctx context.Context, userID uuid.UUID) error + CreateSubscriberFromContract(ctx context.Context, user *contract.UserResponse) error + BulkCreateSubscribers(ctx context.Context, users []*entities.User) error + + // Letter notifications + SendIncomingLetterNotification(ctx context.Context, letterID uuid.UUID, recipientUserID uuid.UUID, subject string, body string) error +} + +type NotificationProcessorImpl struct { + provider NotificationProvider + workflowID string +} + +func NewNotificationProcessor(provider NotificationProvider, workflowID string) *NotificationProcessorImpl { + return &NotificationProcessorImpl{ + provider: provider, + workflowID: workflowID, + } +} + +func (p *NotificationProcessorImpl) CreateSubscriber(ctx context.Context, user *entities.User) error { + return p.provider.CreateSubscriber(ctx, user) +} + +func (p *NotificationProcessorImpl) UpdateSubscriber(ctx context.Context, user *entities.User) error { + return p.provider.UpdateSubscriber(ctx, user) +} + +func (p *NotificationProcessorImpl) DeleteSubscriber(ctx context.Context, userID uuid.UUID) error { + return p.provider.DeleteSubscriber(ctx, userID) +} + +func (p *NotificationProcessorImpl) CreateSubscriberFromContract(ctx context.Context, user *contract.UserResponse) error { + return p.provider.CreateSubscriberFromContract(ctx, user) +} + +func (p *NotificationProcessorImpl) BulkCreateSubscribers(ctx context.Context, users []*entities.User) error { + return p.provider.BulkCreateSubscribers(ctx, users) +} + +func (p *NotificationProcessorImpl) SendIncomingLetterNotification(ctx context.Context, letterID uuid.UUID, recipientUserID uuid.UUID, subject string, body string) error { + // Ensure subscriber exists + if err := p.provider.EnsureSubscriberExists(ctx, recipientUserID); err != nil { + return fmt.Errorf("failed to ensure subscriber exists: %w", err) + } + + // Build notification URL + url := fmt.Sprintf("/en/apps/surat-menyurat/masuk-detail/%s", letterID.String()) + + // Use workflow ID from config (defaults to "notification-dashbpard") + workflowID := p.workflowID + if workflowID == "" { + workflowID = "notification-dashbpard" + } + + // Send notification + return p.provider.SendNotification(ctx, NotificationPayload{ + RecipientID: recipientUserID, + EventName: workflowID, + Data: map[string]interface{}{ + "subject": subject, + "body": body, + "url": url, + }, + }) +} + +// NotificationProvider interface for different notification services +type NotificationProvider interface { + // User management + CreateSubscriber(ctx context.Context, user *entities.User) error + UpdateSubscriber(ctx context.Context, user *entities.User) error + DeleteSubscriber(ctx context.Context, userID uuid.UUID) error + CreateSubscriberFromContract(ctx context.Context, user *contract.UserResponse) error + BulkCreateSubscribers(ctx context.Context, users []*entities.User) error + + // Core notification methods + EnsureSubscriberExists(ctx context.Context, userID uuid.UUID) error + SendNotification(ctx context.Context, payload NotificationPayload) error +} + +type NotificationPayload struct { + RecipientID uuid.UUID + EventName string + Data map[string]interface{} +} + +// NovuProvider implements NotificationProvider using Novu +type NovuProvider struct { + client *novu.APIClient + config *config.NovuConfig +} + +func NewNovuProvider(cfg *config.NovuConfig) *NovuProvider { + if cfg.APIKey == "" { + return &NovuProvider{ + client: nil, + config: cfg, + } + } + + // Create Novu config with backend URL + novuConfig := &novu.Config{} + if cfg.BaseURL != "" { + backendURL, err := url.Parse(cfg.BaseURL) + if err == nil { + novuConfig.BackendURL = backendURL + } + } + + client := novu.NewAPIClient(cfg.APIKey, novuConfig) + + return &NovuProvider{ + client: client, + config: cfg, + } +} + +func (p *NovuProvider) CreateSubscriber(ctx context.Context, user *entities.User) error { + if p.client == nil { + return fmt.Errorf("novu client not initialized") + } + + subscriberID := user.ID.String() + + data := map[string]interface{}{ + "userId": user.ID.String(), + "email": user.Email, + "isActive": user.IsActive, + "createdAt": user.CreatedAt, + } + + if user.Departments != nil && len(user.Departments) > 0 { + depts := make([]map[string]interface{}, len(user.Departments)) + for i, dept := range user.Departments { + depts[i] = map[string]interface{}{ + "id": dept.ID.String(), + "name": dept.Name, + "code": dept.Code, + } + } + data["departments"] = depts + } + + subscriber := novu.SubscriberPayload{ + Email: user.Email, + FirstName: user.Name, + LastName: "", + Phone: "", + Avatar: "", + Data: data, + } + + _, err := p.client.SubscriberApi.Identify(ctx, subscriberID, subscriber) + if err != nil { + return fmt.Errorf("failed to create subscriber: %w", err) + } + + return nil +} + +func (p *NovuProvider) UpdateSubscriber(ctx context.Context, user *entities.User) error { + if p.client == nil { + return fmt.Errorf("novu client not initialized") + } + + subscriberID := user.ID.String() + + data := map[string]interface{}{ + "userId": user.ID.String(), + "email": user.Email, + "isActive": user.IsActive, + "updatedAt": user.UpdatedAt, + } + + if user.Departments != nil && len(user.Departments) > 0 { + depts := make([]map[string]interface{}, len(user.Departments)) + for i, dept := range user.Departments { + depts[i] = map[string]interface{}{ + "id": dept.ID.String(), + "name": dept.Name, + "code": dept.Code, + } + } + data["departments"] = depts + } + + updateData := novu.SubscriberPayload{ + Email: user.Email, + FirstName: user.Name, + LastName: "", + Phone: "", + Avatar: "", + Data: data, + } + + _, err := p.client.SubscriberApi.Update(ctx, subscriberID, updateData) + if err != nil { + return fmt.Errorf("failed to update subscriber: %w", err) + } + + return nil +} + +func (p *NovuProvider) DeleteSubscriber(ctx context.Context, userID uuid.UUID) error { + if p.client == nil { + return fmt.Errorf("novu client not initialized") + } + + subscriberID := userID.String() + + _, err := p.client.SubscriberApi.Delete(ctx, subscriberID) + if err != nil { + return fmt.Errorf("failed to delete subscriber: %w", err) + } + + return nil +} + +func (p *NovuProvider) CreateSubscriberFromContract(ctx context.Context, user *contract.UserResponse) error { + if p.client == nil { + return fmt.Errorf("novu client not initialized") + } + + subscriberID := user.ID.String() + + data := map[string]interface{}{ + "userId": user.ID.String(), + "email": user.Email, + "isActive": user.IsActive, + "createdAt": user.CreatedAt, + } + + if user.Roles != nil && len(user.Roles) > 0 { + roles := make([]map[string]interface{}, len(user.Roles)) + for i, role := range user.Roles { + roles[i] = map[string]interface{}{ + "id": role.ID.String(), + "name": role.Name, + "code": role.Code, + } + } + data["roles"] = roles + } + + if user.DepartmentResponse != nil && len(user.DepartmentResponse) > 0 { + depts := make([]map[string]interface{}, len(user.DepartmentResponse)) + for i, dept := range user.DepartmentResponse { + depts[i] = map[string]interface{}{ + "id": dept.ID.String(), + "name": dept.Name, + "code": dept.Code, + } + } + data["departments"] = depts + } + + subscriber := novu.SubscriberPayload{ + Email: user.Email, + FirstName: user.Name, + LastName: "", + Phone: "", + Avatar: "", + Data: data, + } + + _, err := p.client.SubscriberApi.Identify(ctx, subscriberID, subscriber) + if err != nil { + return fmt.Errorf("failed to create subscriber from contract: %w", err) + } + + return nil +} + +func (p *NovuProvider) BulkCreateSubscribers(ctx context.Context, users []*entities.User) error { + if p.client == nil { + return fmt.Errorf("novu client not initialized") + } + + var lastErr error + successCount := 0 + + for _, user := range users { + err := p.CreateSubscriber(ctx, user) + if err != nil { + lastErr = err + continue + } + successCount++ + } + + if lastErr != nil && successCount == 0 { + return fmt.Errorf("failed to create any subscribers, last error: %w", lastErr) + } + + if lastErr != nil { + return fmt.Errorf("created %d out of %d subscribers, last error: %w", successCount, len(users), lastErr) + } + + return nil +} + +func (p *NovuProvider) EnsureSubscriberExists(ctx context.Context, userID uuid.UUID) error { + if p.client == nil { + return fmt.Errorf("novu client not initialized") + } + + subscriberID := userID.String() + + // Check if subscriber exists + _, err := p.client.SubscriberApi.Get(ctx, subscriberID) + if err != nil { + // Subscriber doesn't exist, create a basic one + subscriber := novu.SubscriberPayload{ + Email: fmt.Sprintf("%s@placeholder.com", subscriberID), + } + _, err = p.client.SubscriberApi.Identify(ctx, subscriberID, subscriber) + if err != nil { + return fmt.Errorf("failed to ensure subscriber exists: %w", err) + } + } + + return nil +} + +func (p *NovuProvider) SendNotification(ctx context.Context, payload NotificationPayload) error { + if p.client == nil { + return fmt.Errorf("novu client not initialized") + } + + triggerPayload := novu.ITriggerPayloadOptions{ + To: payload.RecipientID.String(), + Payload: payload.Data, + } + + _, err := p.client.EventApi.Trigger(ctx, payload.EventName, triggerPayload) + if err != nil { + return fmt.Errorf("failed to send notification: %w", err) + } + + return nil +} diff --git a/internal/processor/novu_processor.go b/internal/processor/novu_processor.go new file mode 100644 index 0000000..2ff86b4 --- /dev/null +++ b/internal/processor/novu_processor.go @@ -0,0 +1,280 @@ +package processor + +import ( + "context" + "fmt" + "net/url" + + "eslogad-be/internal/config" + "eslogad-be/internal/contract" + "eslogad-be/internal/entities" + + novu "github.com/novuhq/go-novu/lib" + "github.com/google/uuid" +) + +type NovuProcessor interface { + CreateSubscriber(ctx context.Context, user *entities.User) error + UpdateSubscriber(ctx context.Context, user *entities.User) error + DeleteSubscriber(ctx context.Context, userID uuid.UUID) error + CreateSubscriberFromContract(ctx context.Context, user *contract.UserResponse) error + BulkCreateSubscribers(ctx context.Context, users []*entities.User) error + SendLetterNotification(ctx context.Context, letterID uuid.UUID, recipientUserID uuid.UUID, subject string, body string) error +} + +type NovuProcessorImpl struct { + client *novu.APIClient + config *config.NovuConfig +} + +func NewNovuProcessor(cfg *config.NovuConfig) *NovuProcessorImpl { + if cfg.APIKey == "" { + return &NovuProcessorImpl{ + client: nil, + config: cfg, + } + } + + // Create Novu config with backend URL + novuConfig := &novu.Config{} + if cfg.BaseURL != "" { + backendURL, err := url.Parse(cfg.BaseURL) + if err == nil { + novuConfig.BackendURL = backendURL + } + } + + client := novu.NewAPIClient(cfg.APIKey, novuConfig) + + return &NovuProcessorImpl{ + client: client, + config: cfg, + } +} + +func (p *NovuProcessorImpl) CreateSubscriber(ctx context.Context, user *entities.User) error { + if p.client == nil { + return fmt.Errorf("novu client not initialized") + } + + subscriberID := user.ID.String() + + data := map[string]interface{}{ + "userId": user.ID.String(), + "email": user.Email, + "isActive": user.IsActive, + "createdAt": user.CreatedAt, + } + + if user.Departments != nil && len(user.Departments) > 0 { + depts := make([]map[string]interface{}, len(user.Departments)) + for i, dept := range user.Departments { + depts[i] = map[string]interface{}{ + "id": dept.ID.String(), + "name": dept.Name, + "code": dept.Code, + } + } + data["departments"] = depts + } + + subscriber := novu.SubscriberPayload{ + Email: user.Email, + FirstName: user.Name, + LastName: "", + Phone: "", + Avatar: "", + Data: data, + } + + _, err := p.client.SubscriberApi.Identify(ctx, subscriberID, subscriber) + if err != nil { + return fmt.Errorf("failed to create subscriber: %w", err) + } + + return nil +} + +func (p *NovuProcessorImpl) UpdateSubscriber(ctx context.Context, user *entities.User) error { + if p.client == nil { + return fmt.Errorf("novu client not initialized") + } + + subscriberID := user.ID.String() + + data := map[string]interface{}{ + "userId": user.ID.String(), + "email": user.Email, + "isActive": user.IsActive, + "updatedAt": user.UpdatedAt, + } + + if user.Departments != nil && len(user.Departments) > 0 { + depts := make([]map[string]interface{}, len(user.Departments)) + for i, dept := range user.Departments { + depts[i] = map[string]interface{}{ + "id": dept.ID.String(), + "name": dept.Name, + "code": dept.Code, + } + } + data["departments"] = depts + } + + updateData := novu.SubscriberPayload{ + Email: user.Email, + FirstName: user.Name, + LastName: "", + Phone: "", + Avatar: "", + Data: data, + } + + _, err := p.client.SubscriberApi.Update(ctx, subscriberID, updateData) + if err != nil { + return fmt.Errorf("failed to update subscriber: %w", err) + } + + return nil +} + +func (p *NovuProcessorImpl) DeleteSubscriber(ctx context.Context, userID uuid.UUID) error { + if p.client == nil { + return fmt.Errorf("novu client not initialized") + } + + subscriberID := userID.String() + + _, err := p.client.SubscriberApi.Delete(ctx, subscriberID) + if err != nil { + return fmt.Errorf("failed to delete subscriber: %w", err) + } + + return nil +} + +func (p *NovuProcessorImpl) CreateSubscriberFromContract(ctx context.Context, user *contract.UserResponse) error { + if p.client == nil { + return fmt.Errorf("novu client not initialized") + } + + subscriberID := user.ID.String() + + data := map[string]interface{}{ + "userId": user.ID.String(), + "email": user.Email, + "isActive": user.IsActive, + "createdAt": user.CreatedAt, + } + + if user.Roles != nil && len(user.Roles) > 0 { + roles := make([]map[string]interface{}, len(user.Roles)) + for i, role := range user.Roles { + roles[i] = map[string]interface{}{ + "id": role.ID.String(), + "name": role.Name, + "code": role.Code, + } + } + data["roles"] = roles + } + + if user.DepartmentResponse != nil && len(user.DepartmentResponse) > 0 { + depts := make([]map[string]interface{}, len(user.DepartmentResponse)) + for i, dept := range user.DepartmentResponse { + depts[i] = map[string]interface{}{ + "id": dept.ID.String(), + "name": dept.Name, + "code": dept.Code, + } + } + data["departments"] = depts + } + + subscriber := novu.SubscriberPayload{ + Email: user.Email, + FirstName: user.Name, + LastName: "", + Phone: "", + Avatar: "", + Data: data, + } + + _, err := p.client.SubscriberApi.Identify(ctx, subscriberID, subscriber) + if err != nil { + return fmt.Errorf("failed to create subscriber from contract: %w", err) + } + + return nil +} + +func (p *NovuProcessorImpl) BulkCreateSubscribers(ctx context.Context, users []*entities.User) error { + if p.client == nil { + return fmt.Errorf("novu client not initialized") + } + + var lastErr error + successCount := 0 + + for _, user := range users { + err := p.CreateSubscriber(ctx, user) + if err != nil { + lastErr = err + continue + } + successCount++ + } + + if lastErr != nil && successCount == 0 { + return fmt.Errorf("failed to create any subscribers, last error: %w", lastErr) + } + + if lastErr != nil { + return fmt.Errorf("created %d out of %d subscribers, last error: %w", successCount, len(users), lastErr) + } + + return nil +} + +func (p *NovuProcessorImpl) SendLetterNotification(ctx context.Context, letterID uuid.UUID, recipientUserID uuid.UUID, subject string, body string) error { + if p.client == nil { + return fmt.Errorf("novu client not initialized") + } + + subscriberID := recipientUserID.String() + + // Check if subscriber exists, create if not + _, err := p.client.SubscriberApi.Get(ctx, subscriberID) + if err != nil { + // Subscriber doesn't exist, create a basic one + subscriber := novu.SubscriberPayload{ + Email: fmt.Sprintf("%s@placeholder.com", subscriberID), + } + _, err = p.client.SubscriberApi.Identify(ctx, subscriberID, subscriber) + if err != nil { + return fmt.Errorf("failed to ensure subscriber exists: %w", err) + } + } + + // Prepare notification payload + url := fmt.Sprintf("en/apps/surat-menyurat/masuk-detail/%s", letterID.String()) + + payload := map[string]interface{}{ + "subject": subject, + "body": body, + "url": url, + } + + // Trigger the notification + triggerPayload := novu.ITriggerPayloadOptions{ + To: subscriberID, + Payload: payload, + } + + _, err = p.client.EventApi.Trigger(ctx, "notification-dashboard", triggerPayload) + if err != nil { + return fmt.Errorf("failed to send letter notification: %w", err) + } + + return nil +} \ No newline at end of file diff --git a/internal/processor/recipient_processor.go b/internal/processor/recipient_processor.go new file mode 100644 index 0000000..626addd --- /dev/null +++ b/internal/processor/recipient_processor.go @@ -0,0 +1,96 @@ +package processor + +import ( + "context" + + "eslogad-be/internal/entities" + "eslogad-be/internal/repository" + + "github.com/google/uuid" +) + +type RecipientProcessor interface { + CreateDefaultRecipients(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingRecipient, error) + CreateRecipients(ctx context.Context, letterID uuid.UUID, departmentIDs []uuid.UUID) ([]entities.LetterIncomingRecipient, error) + CreateSingleRecipient(ctx context.Context, recipient *entities.LetterIncomingRecipient) error +} + +type RecipientProcessorImpl struct { + recipientRepo *repository.LetterIncomingRecipientRepository + settingRepo *repository.AppSettingRepository + departmentRepo *repository.DepartmentRepository + userDeptRepo *repository.UserDepartmentRepository +} + +func NewRecipientProcessor( + recipientRepo *repository.LetterIncomingRecipientRepository, + settingRepo *repository.AppSettingRepository, + departmentRepo *repository.DepartmentRepository, + userDeptRepo *repository.UserDepartmentRepository, +) *RecipientProcessorImpl { + return &RecipientProcessorImpl{ + recipientRepo: recipientRepo, + settingRepo: settingRepo, + departmentRepo: departmentRepo, + userDeptRepo: userDeptRepo, + } +} + +func (p *RecipientProcessorImpl) CreateDefaultRecipients(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingRecipient, error) { + departmentIDs, err := p.settingRepo.GetDepartmentRecipients(ctx) + if err != nil { + return []entities.LetterIncomingRecipient{}, nil + } + + if len(departmentIDs) == 0 { + return []entities.LetterIncomingRecipient{}, nil + } + + return p.CreateRecipients(ctx, letterID, departmentIDs) +} + +func (p *RecipientProcessorImpl) CreateRecipients(ctx context.Context, letterID uuid.UUID, departmentIDs []uuid.UUID) ([]entities.LetterIncomingRecipient, error) { + if len(departmentIDs) == 0 { + return []entities.LetterIncomingRecipient{}, nil + } + + userMemberships, err := p.userDeptRepo.ListActiveByDepartmentIDs(ctx, departmentIDs) + if err != nil { + return nil, err + } + + recipients := p.buildUniqueRecipients(letterID, userMemberships) + + if len(recipients) > 0 { + if err := p.recipientRepo.CreateBulk(ctx, recipients); err != nil { + return nil, err + } + } + + return recipients, nil +} + +func (p *RecipientProcessorImpl) buildUniqueRecipients(letterID uuid.UUID, userMemberships []repository.UserDepartmentRow) []entities.LetterIncomingRecipient { + var recipients []entities.LetterIncomingRecipient + userMap := make(map[string]bool) + + for _, membership := range userMemberships { + userIDStr := membership.UserID.String() + + if !userMap[userIDStr] { + recipients = append(recipients, entities.LetterIncomingRecipient{ + LetterID: letterID, + RecipientUserID: &membership.UserID, + RecipientDepartmentID: &membership.DepartmentID, + Status: entities.RecipientStatusNew, + }) + userMap[userIDStr] = true + } + } + + return recipients +} + +func (p *RecipientProcessorImpl) CreateSingleRecipient(ctx context.Context, recipient *entities.LetterIncomingRecipient) error { + return p.recipientRepo.Create(ctx, recipient) +} diff --git a/internal/processor/user_processor.go b/internal/processor/user_processor.go index c4a103f..09ab6be 100644 --- a/internal/processor/user_processor.go +++ b/internal/processor/user_processor.go @@ -14,8 +14,9 @@ import ( ) type UserProcessorImpl struct { - userRepo UserRepository - profileRepo UserProfileRepository + userRepo UserRepository + profileRepo UserProfileRepository + novuProcessor NovuProcessor } type UserProfileRepository interface { @@ -35,6 +36,10 @@ func NewUserProcessor( } } +func (p *UserProcessorImpl) SetNovuProcessor(novuProcessor NovuProcessor) { + p.novuProcessor = novuProcessor +} + func (p *UserProcessorImpl) CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error) { existingUser, err := p.userRepo.GetByEmail(ctx, req.Email) if err == nil && existingUser != nil { @@ -65,6 +70,15 @@ func (p *UserProcessorImpl) CreateUser(ctx context.Context, req *contract.Create } _ = p.profileRepo.Create(ctx, profile) + // Create Novu subscriber + if p.novuProcessor != nil { + if err := p.novuProcessor.CreateSubscriber(ctx, userEntity); err != nil { + // Log error but don't fail user creation + // You might want to add proper logging here + _ = err + } + } + return transformer.EntityToContract(userEntity), nil } @@ -88,6 +102,13 @@ func (p *UserProcessorImpl) UpdateUser(ctx context.Context, id uuid.UUID, req *c return nil, fmt.Errorf("failed to update user: %w", err) } + // Update Novu subscriber + if p.novuProcessor != nil { + if err := p.novuProcessor.UpdateSubscriber(ctx, updated); err != nil { + _ = err + } + } + return transformer.EntityToContract(updated), nil } @@ -102,6 +123,14 @@ func (p *UserProcessorImpl) DeleteUser(ctx context.Context, id uuid.UUID) error return fmt.Errorf("failed to delete user: %w", err) } + // Delete Novu subscriber + if p.novuProcessor != nil { + if err := p.novuProcessor.DeleteSubscriber(ctx, id); err != nil { + // Log error but don't fail user deletion + _ = err + } + } + return nil } @@ -121,6 +150,25 @@ func (p *UserProcessorImpl) GetUserByID(ctx context.Context, id uuid.UUID) (*con return resp, nil } +// GetUserByIDLight retrieves user without relationships - optimized for auth checks +func (p *UserProcessorImpl) GetUserByIDLight(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) { + user, err := p.userRepo.GetByIDLight(ctx, id) + if err != nil { + return nil, fmt.Errorf("user not found: %w", err) + } + + resp := &contract.UserResponse{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + IsActive: user.IsActive, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } + + return resp, nil +} + func (p *UserProcessorImpl) GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error) { user, err := p.userRepo.GetByEmail(ctx, email) if err != nil { diff --git a/internal/processor/user_repository.go b/internal/processor/user_repository.go index a9a2efa..105f05f 100644 --- a/internal/processor/user_repository.go +++ b/internal/processor/user_repository.go @@ -10,6 +10,7 @@ import ( type UserRepository interface { Create(ctx context.Context, user *entities.User) error GetByID(ctx context.Context, id uuid.UUID) (*entities.User, error) + GetByIDLight(ctx context.Context, id uuid.UUID) (*entities.User, error) GetByEmail(ctx context.Context, email string) (*entities.User, error) GetByRole(ctx context.Context, role entities.UserRole) ([]*entities.User, error) GetActiveUsers(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error) diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index c9d138d..e49fd8d 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -177,14 +177,18 @@ func (r *AnalyticsRepository) GetLetterSummaryStats(ctx context.Context, startDa stats["total_archived"] = archivedCount // Calculate average processing time - var avgProcessingTime float64 + var avgProcessingTime *float64 db.Table("letters_outgoing"). Select("AVG(EXTRACT(EPOCH FROM (letters_outgoing.updated_at - letters_outgoing.created_at))/3600) as avg_hours"). Where("letters_outgoing.status IN ('approved', 'sent', 'archived')"). Where("letters_outgoing.deleted_at IS NULL"). Scan(&avgProcessingTime) - stats["avg_processing_time"] = avgProcessingTime + if avgProcessingTime != nil { + stats["avg_processing_time"] = *avgProcessingTime + } else { + stats["avg_processing_time"] = float64(0) + } // Calculate completion rate var completedCount int64 diff --git a/internal/repository/app_setting_repository.go b/internal/repository/app_setting_repository.go index db8e3ea..5b17612 100644 --- a/internal/repository/app_setting_repository.go +++ b/internal/repository/app_setting_repository.go @@ -2,7 +2,11 @@ package repository import ( "context" + "encoding/json" + "eslogad-be/internal/contract" "eslogad-be/internal/entities" + + "github.com/google/uuid" "gorm.io/gorm" ) @@ -26,3 +30,33 @@ func (r *AppSettingRepository) Upsert(ctx context.Context, key string, value ent db := DBFromContext(ctx, r.db) return db.WithContext(ctx).Exec("INSERT INTO app_settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP", key, value).Error } + +func (r *AppSettingRepository) GetDepartmentRecipients(ctx context.Context) ([]uuid.UUID, error) { + setting, err := r.Get(ctx, contract.SettingIncomingLetterDepartmentRecipients) + if err != nil { + if err == gorm.ErrRecordNotFound { + return []uuid.UUID{}, nil + } + return nil, err + } + + jsonBytes, err := json.Marshal(setting.Value) + if err != nil { + return []uuid.UUID{}, nil + } + + // Try to unmarshal as the structured format first + var recipientSetting entities.DepartmentRecipientsSetting + if err := json.Unmarshal(jsonBytes, &recipientSetting); err == nil { + return recipientSetting.DepartmentIDs, nil + } + + // If that fails, try to unmarshal as a direct array of UUIDs + var departmentIDs []uuid.UUID + if err := json.Unmarshal(jsonBytes, &departmentIDs); err == nil { + return departmentIDs, nil + } + + // If both fail, return empty array + return []uuid.UUID{}, nil +} diff --git a/internal/repository/disposition_route_repository.go b/internal/repository/disposition_route_repository.go index b65c61d..e328355 100644 --- a/internal/repository/disposition_route_repository.go +++ b/internal/repository/disposition_route_repository.go @@ -19,10 +19,106 @@ func (r *DispositionRouteRepository) Create(ctx context.Context, e *entities.Dis db := DBFromContext(ctx, r.db) return db.WithContext(ctx).Create(e).Error } + +// Upsert creates or updates a disposition route based on from_department_id and to_department_id +func (r *DispositionRouteRepository) Upsert(ctx context.Context, e *entities.DispositionRoute) error { + db := DBFromContext(ctx, r.db) + + // Check if route exists + var existing entities.DispositionRoute + err := db.WithContext(ctx). + Where("from_department_id = ? AND to_department_id = ?", e.FromDepartmentID, e.ToDepartmentID). + First(&existing).Error + + if err == gorm.ErrRecordNotFound { + // Create new route + return db.WithContext(ctx).Create(e).Error + } else if err != nil { + return err + } + + // Update existing route + e.ID = existing.ID + return db.WithContext(ctx).Model(&entities.DispositionRoute{}). + Where("id = ?", existing.ID). + Updates(e).Error +} + +// BulkUpsert performs bulk create or update for multiple routes +func (r *DispositionRouteRepository) BulkUpsert(ctx context.Context, fromDeptID uuid.UUID, toDeptIDs []uuid.UUID, isActive bool, allowedActions entities.JSONB) (created int, updated int, err error) { + db := DBFromContext(ctx, r.db) + + // Start transaction + tx := db.WithContext(ctx).Begin() + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // Get existing routes for this from_department_id + var existingRoutes []entities.DispositionRoute + if err = tx.Where("from_department_id = ?", fromDeptID).Find(&existingRoutes).Error; err != nil { + return 0, 0, err + } + + // Create map of existing routes + existingMap := make(map[uuid.UUID]entities.DispositionRoute) + for _, route := range existingRoutes { + existingMap[route.ToDepartmentID] = route + } + + // Process each to_department_id + for _, toDeptID := range toDeptIDs { + route := entities.DispositionRoute{ + FromDepartmentID: fromDeptID, + ToDepartmentID: toDeptID, + IsActive: isActive, + AllowedActions: allowedActions, + } + + if existing, exists := existingMap[toDeptID]; exists { + // Update existing route + route.ID = existing.ID + if err = tx.Model(&entities.DispositionRoute{}). + Where("id = ?", existing.ID). + Updates(&route).Error; err != nil { + return created, updated, err + } + updated++ + // Remove from map to track which routes to delete + delete(existingMap, toDeptID) + } else { + // Create new route + if err = tx.Create(&route).Error; err != nil { + return created, updated, err + } + created++ + } + } + + // Optionally deactivate routes that are no longer in the list + // (routes that exist in DB but not in the new list) + for _, oldRoute := range existingMap { + if err = tx.Model(&entities.DispositionRoute{}). + Where("id = ?", oldRoute.ID). + Update("is_active", false).Error; err != nil { + return created, updated, err + } + } + + // Commit transaction + if err = tx.Commit().Error; err != nil { + return 0, 0, err + } + + return created, updated, nil +} func (r *DispositionRouteRepository) Update(ctx context.Context, e *entities.DispositionRoute) error { db := DBFromContext(ctx, r.db) return db.WithContext(ctx).Model(&entities.DispositionRoute{}).Where("id = ?", e.ID).Updates(e).Error } + func (r *DispositionRouteRepository) Get(ctx context.Context, id uuid.UUID) (*entities.DispositionRoute, error) { db := DBFromContext(ctx, r.db) var e entities.DispositionRoute @@ -46,7 +142,71 @@ func (r *DispositionRouteRepository) ListByFromDept(ctx context.Context, fromDep return list, nil } +func (r *DispositionRouteRepository) IsEligibleForDisposition(ctx context.Context, fromDept uuid.UUID) (bool, error) { + db := DBFromContext(ctx, r.db) + var list []entities.DispositionRoute + if err := db.WithContext(ctx).Where("from_department_id = ?", fromDept).Find(&list).Error; err != nil { + return false, err + } + return len(list) > 0, 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 } + +// ListAllGrouped returns all disposition routes grouped by from_department_id +func (r *DispositionRouteRepository) ListAllGrouped(ctx context.Context) (map[uuid.UUID][]uuid.UUID, error) { + db := DBFromContext(ctx, r.db) + var routes []entities.DispositionRoute + + if err := db.WithContext(ctx). + Where("is_active = ?", true). + Order("from_department_id, to_department_id"). + Find(&routes).Error; err != nil { + return nil, err + } + + // Group by from_department_id + grouped := make(map[uuid.UUID][]uuid.UUID) + for _, route := range routes { + grouped[route.FromDepartmentID] = append(grouped[route.FromDepartmentID], route.ToDepartmentID) + } + + return grouped, nil +} + +// ListAllGroupedWithDepartments returns all disposition routes grouped by from_department_id with department details +func (r *DispositionRouteRepository) ListAllGroupedWithDepartments(ctx context.Context) ([]entities.DispositionRoute, error) { + db := DBFromContext(ctx, r.db) + var routes []entities.DispositionRoute + + if err := db.WithContext(ctx). + Preload("FromDepartment"). + Preload("ToDepartment"). + Where("is_active = ?", true). + Order("from_department_id, to_department_id"). + Find(&routes).Error; err != nil { + return nil, err + } + + return routes, nil +} + +// ListAll returns all disposition routes with department details +func (r *DispositionRouteRepository) ListAll(ctx context.Context) ([]entities.DispositionRoute, error) { + db := DBFromContext(ctx, r.db) + var routes []entities.DispositionRoute + + if err := db.WithContext(ctx). + Preload("FromDepartment"). + Preload("ToDepartment"). + Where("is_active = ?", true). + Order("from_department_id, to_department_id"). + Find(&routes).Error; err != nil { + return nil, err + } + + return routes, nil +} diff --git a/internal/repository/letter_outgoing_repository.go b/internal/repository/letter_outgoing_repository.go index 54c2741..30131c2 100644 --- a/internal/repository/letter_outgoing_repository.go +++ b/internal/repository/letter_outgoing_repository.go @@ -51,6 +51,15 @@ func (r *LetterOutgoingRepository) SoftDelete(ctx context.Context, id uuid.UUID) return db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("id = ? AND deleted_at IS NULL", id).Update("deleted_at", now).Error } +func (r *LetterOutgoingRepository) BulkArchive(ctx context.Context, letterIDs []uuid.UUID) (int64, error) { + db := DBFromContext(ctx, r.db) + result := db.WithContext(ctx). + Model(&entities.LetterOutgoing{}). + Where("id IN ? AND deleted_at IS NULL", letterIDs). + Update("status", "archived") + return result.RowsAffected, result.Error +} + func (r *LetterOutgoingRepository) GetWithRelations(ctx context.Context, id uuid.UUID, relations []string) (*entities.LetterOutgoing, error) { db := DBFromContext(ctx, r.db) query := db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id) @@ -83,12 +92,22 @@ type ListOutgoingLettersFilter struct { PriorityID *uuid.UUID SortBy *string SortOrder *string + IsArchived *bool } func (r *LetterOutgoingRepository) List(ctx context.Context, filter ListOutgoingLettersFilter, limit, offset int) ([]entities.LetterOutgoing, int64, error) { db := DBFromContext(ctx, r.db) query := db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("deleted_at IS NULL") + // Apply is_archived filter + if filter.IsArchived != nil { + if *filter.IsArchived { + query = query.Where("status = 'archived'") + } else { + query = query.Where("status != 'archived'") + } + } + if filter.Status != nil { query = query.Where("status = ?", *filter.Status) } @@ -207,6 +226,27 @@ func (r *LetterOutgoingAttachmentRepository) Delete(ctx context.Context, id uuid return db.WithContext(ctx).Where("id = ?", id).Delete(&entities.LetterOutgoingAttachment{}).Error } +// ListByLetterIDs fetches attachments for multiple letters in a single query +func (r *LetterOutgoingAttachmentRepository) ListByLetterIDs(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterOutgoingAttachment, error) { + if len(letterIDs) == 0 { + return make(map[uuid.UUID][]entities.LetterOutgoingAttachment), nil + } + + db := DBFromContext(ctx, r.db) + var attachments []entities.LetterOutgoingAttachment + if err := db.WithContext(ctx).Where("letter_id IN ?", letterIDs).Order("uploaded_at ASC").Find(&attachments).Error; err != nil { + return nil, err + } + + // Group attachments by letter ID + result := make(map[uuid.UUID][]entities.LetterOutgoingAttachment) + for _, att := range attachments { + result[att.LetterID] = append(result[att.LetterID], att) + } + + return result, nil +} + type LetterOutgoingRecipientRepository struct{ db *gorm.DB } func NewLetterOutgoingRecipientRepository(db *gorm.DB) *LetterOutgoingRecipientRepository { @@ -226,6 +266,27 @@ func (r *LetterOutgoingRecipientRepository) CreateBulk(ctx context.Context, list return db.WithContext(ctx).Create(&list).Error } +func (r *LetterOutgoingRecipientRepository) CountUnreadByUser(ctx context.Context, userID uuid.UUID) (int, error) { + db := DBFromContext(ctx, r.db) + var count int64 + if err := db.WithContext(ctx). + Model(&entities.LetterOutgoingRecipient{}). + Where("user_id = ? AND read_at IS NULL", userID). + Count(&count).Error; err != nil { + return 0, err + } + return int(count), nil +} + +func (r *LetterOutgoingRecipientRepository) MarkAsRead(ctx context.Context, letterID, userID uuid.UUID) error { + db := DBFromContext(ctx, r.db) + now := time.Now() + return db.WithContext(ctx). + Model(&entities.LetterOutgoingRecipient{}). + Where("letter_id = ? AND user_id = ?", letterID, userID). + Update("read_at", now).Error +} + func (r *LetterOutgoingRecipientRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingRecipient, error) { db := DBFromContext(ctx, r.db) var list []entities.LetterOutgoingRecipient @@ -255,6 +316,33 @@ func (r *LetterOutgoingRecipientRepository) DeleteByLetter(ctx context.Context, return db.WithContext(ctx).Where("letter_id = ?", letterID).Delete(&entities.LetterOutgoingRecipient{}).Error } +// ListByLetterIDs fetches recipients for multiple letters in a single query +func (r *LetterOutgoingRecipientRepository) ListByLetterIDs(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterOutgoingRecipient, error) { + if len(letterIDs) == 0 { + return make(map[uuid.UUID][]entities.LetterOutgoingRecipient), nil + } + + db := DBFromContext(ctx, r.db) + var recipients []entities.LetterOutgoingRecipient + if err := db.WithContext(ctx). + Preload("User"). + Preload("User.Profile"). + Preload("Department"). + Where("letter_id IN ?", letterIDs). + Order("is_primary DESC, created_at ASC"). + Find(&recipients).Error; err != nil { + return nil, err + } + + // Group recipients by letter ID + result := make(map[uuid.UUID][]entities.LetterOutgoingRecipient) + for _, rec := range recipients { + result[rec.LetterID] = append(result[rec.LetterID], rec) + } + + return result, nil +} + type LetterOutgoingDiscussionRepository struct{ db *gorm.DB } func NewLetterOutgoingDiscussionRepository(db *gorm.DB) *LetterOutgoingDiscussionRepository { diff --git a/internal/repository/letter_repository.go b/internal/repository/letter_repository.go index 165b907..5ea1519 100644 --- a/internal/repository/letter_repository.go +++ b/internal/repository/letter_repository.go @@ -29,6 +29,10 @@ func (r *LetterIncomingRepository) Get(ctx context.Context, id uuid.UUID) (*enti return &e, nil } +func (r *LetterIncomingRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.LetterIncoming, error) { + return r.Get(ctx, id) +} + func (r *LetterIncomingRepository) Update(ctx context.Context, e *entities.LetterIncoming) error { db := DBFromContext(ctx, r.db) return db.WithContext(ctx).Model(&entities.LetterIncoming{}).Where("id = ? AND deleted_at IS NULL", e.ID).Updates(e).Error @@ -39,21 +43,97 @@ func (r *LetterIncomingRepository) SoftDelete(ctx context.Context, id uuid.UUID) return db.WithContext(ctx).Exec("UPDATE letters_incoming SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL", id).Error } +func (r *LetterIncomingRepository) BulkArchive(ctx context.Context, letterIDs []uuid.UUID) (int64, error) { + db := DBFromContext(ctx, r.db) + // For incoming letters, we archive the recipients, not the letter itself + // The letter status remains as is (new, in_progress, or completed) + result := db.WithContext(ctx). + Model(&entities.LetterIncomingRecipient{}). + Where("letter_id IN ?", letterIDs). + Update("is_archived", true) + return result.RowsAffected, result.Error +} + +// BulkArchiveForUser archives letters for a specific user only +func (r *LetterIncomingRepository) BulkArchiveForUser(ctx context.Context, letterIDs []uuid.UUID, userID uuid.UUID) (int64, error) { + db := DBFromContext(ctx, r.db) + // Archive only the recipient records for the specific user + // Note: letter_incoming_recipients uses recipient_user_id column + result := db.WithContext(ctx). + Model(&entities.LetterIncomingRecipient{}). + Where("letter_id IN ? AND recipient_user_id = ?", letterIDs, userID). + Update("is_archived", true) + return result.RowsAffected, result.Error +} + type ListIncomingLettersFilter struct { - Status *string - Query *string - DepartmentID *uuid.UUID + Status *string + Query *string + DepartmentID *uuid.UUID + UserID *uuid.UUID + IsRead *bool + PriorityIDs []uuid.UUID + IsDispositioned *bool + IsArchived *bool } func (r *LetterIncomingRepository) List(ctx context.Context, filter ListIncomingLettersFilter, limit, offset int) ([]entities.LetterIncoming, int64, error) { db := DBFromContext(ctx, r.db) query := db.WithContext(ctx).Model(&entities.LetterIncoming{}).Where("deleted_at IS NULL") - + + joinedRecipients := false + needsGroupBy := false + if filter.DepartmentID != nil { query = query.Joins("JOIN letter_incoming_recipients ON letter_incoming_recipients.letter_id = letters_incoming.id"). Where("letter_incoming_recipients.recipient_department_id = ?", *filter.DepartmentID) + joinedRecipients = true + needsGroupBy = true } - + + // Apply is_read filter if UserID is provided + if filter.UserID != nil && filter.IsRead != nil { + if !joinedRecipients { + query = query.Joins("JOIN letter_incoming_recipients ON letter_incoming_recipients.letter_id = letters_incoming.id") + joinedRecipients = true + needsGroupBy = true + } + query = query.Where("letter_incoming_recipients.recipient_user_id = ?", *filter.UserID) + + if *filter.IsRead { + query = query.Where("letter_incoming_recipients.read_at IS NOT NULL") + } else { + query = query.Where("letter_incoming_recipients.read_at IS NULL") + } + } + + // Apply is_dispositioned filter if DepartmentID is provided + if filter.DepartmentID != nil && filter.IsDispositioned != nil { + query = query.Joins("LEFT JOIN letter_incoming_dispositions_department lidd ON lidd.letter_incoming_id = letters_incoming.id AND lidd.department_id = ?", *filter.DepartmentID) + + if *filter.IsDispositioned { + // Has been dispositioned (status is not 'pending' or record exists with non-pending status) + query = query.Where("lidd.id IS NOT NULL AND lidd.status != 'pending'") + } else { + // Not yet dispositioned (no record or status is 'pending') + query = query.Where("lidd.id IS NULL OR lidd.status = 'pending'") + } + } + + // Apply priority filter + if len(filter.PriorityIDs) > 0 { + query = query.Where("letters_incoming.priority_id IN ?", filter.PriorityIDs) + } + + // Apply is_archived filter based on recipient's is_archived field + if filter.IsArchived != nil { + if *filter.IsArchived { + query = query.Where("letter_incoming_recipients.is_archived = ?", true) + } else { + query = query.Where("letter_incoming_recipients.is_archived = ? OR letter_incoming_recipients.is_archived IS NULL", false) + } + } + if filter.Status != nil { query = query.Where("letters_incoming.status = ?", *filter.Status) } @@ -61,12 +141,76 @@ func (r *LetterIncomingRepository) List(ctx context.Context, filter ListIncoming q := "%" + *filter.Query + "%" query = query.Where("letters_incoming.subject ILIKE ? OR letters_incoming.reference_number ILIKE ?", q, q) } + + // Use GROUP BY instead of DISTINCT to handle joins properly + if needsGroupBy { + query = query.Group("letters_incoming.id") + } + var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, err } + + // For the actual data fetch, we need to select all columns var list []entities.LetterIncoming - if err := query.Order("letters_incoming.created_at DESC").Limit(limit).Offset(offset).Find(&list).Error; err != nil { + dataQuery := db.WithContext(ctx).Model(&entities.LetterIncoming{}).Where("deleted_at IS NULL") + + if filter.DepartmentID != nil { + dataQuery = dataQuery.Joins("JOIN letter_incoming_recipients ON letter_incoming_recipients.letter_id = letters_incoming.id"). + Where("letter_incoming_recipients.recipient_department_id = ?", *filter.DepartmentID) + } + + if filter.UserID != nil && filter.IsRead != nil { + if filter.DepartmentID == nil { + dataQuery = dataQuery.Joins("JOIN letter_incoming_recipients ON letter_incoming_recipients.letter_id = letters_incoming.id") + } + dataQuery = dataQuery.Where("letter_incoming_recipients.recipient_user_id = ?", *filter.UserID) + + if *filter.IsRead { + dataQuery = dataQuery.Where("letter_incoming_recipients.read_at IS NOT NULL") + } else { + dataQuery = dataQuery.Where("letter_incoming_recipients.read_at IS NULL") + } + } + + if filter.DepartmentID != nil && filter.IsDispositioned != nil { + dataQuery = dataQuery.Joins("LEFT JOIN letter_incoming_dispositions_department lidd ON lidd.letter_incoming_id = letters_incoming.id AND lidd.department_id = ?", *filter.DepartmentID) + + if *filter.IsDispositioned { + dataQuery = dataQuery.Where("lidd.id IS NOT NULL AND lidd.status != 'pending'") + } else { + dataQuery = dataQuery.Where("lidd.id IS NULL OR lidd.status = 'pending'") + } + } + + if len(filter.PriorityIDs) > 0 { + dataQuery = dataQuery.Where("letters_incoming.priority_id IN ?", filter.PriorityIDs) + } + + // Apply is_archived filter based on recipient's is_archived field + if filter.IsArchived != nil { + if *filter.IsArchived { + dataQuery = dataQuery.Where("letter_incoming_recipients.is_archived = ?", true) + } else { + dataQuery = dataQuery.Where("letter_incoming_recipients.is_archived = ? OR letter_incoming_recipients.is_archived IS NULL", false) + } + } + + if filter.Status != nil { + dataQuery = dataQuery.Where("letters_incoming.status = ?", *filter.Status) + } + + if filter.Query != nil { + q := "%" + *filter.Query + "%" + dataQuery = dataQuery.Where("letters_incoming.subject ILIKE ? OR letters_incoming.reference_number ILIKE ?", q, q) + } + + if needsGroupBy { + dataQuery = dataQuery.Group("letters_incoming.id, letters_incoming.letter_number, letters_incoming.reference_number, letters_incoming.subject, letters_incoming.description, letters_incoming.priority_id, letters_incoming.sender_institution_id, letters_incoming.received_date, letters_incoming.due_date, letters_incoming.status, letters_incoming.created_by, letters_incoming.created_at, letters_incoming.updated_at, letters_incoming.deleted_at") + } + + if err := dataQuery.Order("letters_incoming.created_at DESC").Limit(limit).Offset(offset).Find(&list).Error; err != nil { return nil, 0, err } return list, total, nil @@ -91,6 +235,26 @@ func (r *LetterIncomingAttachmentRepository) ListByLetter(ctx context.Context, l return list, nil } +func (r *LetterIncomingAttachmentRepository) ListByLetterIDs(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterIncomingAttachment, error) { + if len(letterIDs) == 0 { + return make(map[uuid.UUID][]entities.LetterIncomingAttachment), nil + } + + db := DBFromContext(ctx, r.db) + var attachments []entities.LetterIncomingAttachment + if err := db.WithContext(ctx).Where("letter_id IN ?", letterIDs).Order("uploaded_at ASC").Find(&attachments).Error; err != nil { + return nil, err + } + + // Group attachments by letter ID + result := make(map[uuid.UUID][]entities.LetterIncomingAttachment) + for _, att := range attachments { + result[att.LetterID] = append(result[att.LetterID], att) + } + + return result, nil +} + type LetterIncomingActivityLogRepository struct{ db *gorm.DB } func NewLetterIncomingActivityLogRepository(db *gorm.DB) *LetterIncomingActivityLogRepository { @@ -120,6 +284,21 @@ func (r *LetterIncomingDispositionRepository) Create(ctx context.Context, e *ent db := DBFromContext(ctx, r.db) return db.WithContext(ctx).Create(e).Error } + +func (r *LetterIncomingDispositionRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.LetterIncomingDisposition, error) { + db := DBFromContext(ctx, r.db) + var e entities.LetterIncomingDisposition + if err := db.WithContext(ctx). + Preload("Department"). + Preload("Departments.Department"). + Preload("ActionSelections.Action"). + Preload("DispositionNotes.User"). + First(&e, "id = ?", id).Error; err != nil { + return nil, err + } + return &e, nil +} + func (r *LetterIncomingDispositionRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingDisposition, error) { db := DBFromContext(ctx, r.db) var list []entities.LetterIncomingDisposition @@ -136,15 +315,149 @@ func (r *LetterIncomingDispositionRepository) ListByLetter(ctx context.Context, return list, nil } +func (r *LetterIncomingDispositionRepository) GetByLetterIncomingID(ctx context.Context, letterIncomingID uuid.UUID) ([]entities.LetterIncomingDisposition, error) { + db := DBFromContext(ctx, r.db) + var list []entities.LetterIncomingDisposition + if err := db.WithContext(ctx). + Where("letter_id = ?", letterIncomingID). + Preload("Department"). + Preload("Departments.Department"). + 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) DB(ctx context.Context) *gorm.DB { + return DBFromContext(ctx, r.db) +} + +func (r *LetterIncomingDispositionDepartmentRepository) Create(ctx context.Context, e *entities.LetterIncomingDispositionDepartment) error { + db := DBFromContext(ctx, r.db) + return db.WithContext(ctx).Create(e).Error +} + 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) Update(ctx context.Context, e *entities.LetterIncomingDispositionDepartment) error { + db := DBFromContext(ctx, r.db) + return db.WithContext(ctx).Save(e).Error +} + +func (r *LetterIncomingDispositionDepartmentRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.LetterIncomingDispositionDepartment, error) { + db := DBFromContext(ctx, r.db) + var e entities.LetterIncomingDispositionDepartment + if err := db.WithContext(ctx). + Preload("Department"). + Preload("LetterIncoming"). + Preload("LetterIncomingDisposition"). + First(&e, "id = ?", id).Error; err != nil { + return nil, err + } + return &e, nil +} + +func (r *LetterIncomingDispositionDepartmentRepository) GetByDispositionAndDepartment(ctx context.Context, letterIncomingID, departmentID uuid.UUID) (*entities.LetterIncomingDispositionDepartment, error) { + db := DBFromContext(ctx, r.db) + var e entities.LetterIncomingDispositionDepartment + if err := db.WithContext(ctx). + Where("letter_incoming_id = ? AND department_id = ?", letterIncomingID, departmentID). + Preload("Department"). + Preload("LetterIncoming"). + Preload("LetterIncomingDisposition"). + First(&e).Error; err != nil { + return nil, err + } + return &e, nil +} + +func (r *LetterIncomingDispositionDepartmentRepository) GetByLetterAndDepartment(ctx context.Context, letterID, departmentID uuid.UUID) ([]entities.LetterIncomingDispositionDepartment, error) { + db := DBFromContext(ctx, r.db) + var list []entities.LetterIncomingDispositionDepartment + if err := db.WithContext(ctx). + Where("letter_incoming_id = ? AND department_id = ?", letterID, departmentID). + Preload("Department"). + Preload("LetterIncoming"). + Preload("LetterIncomingDisposition"). + Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func (r *LetterIncomingDispositionDepartmentRepository) ListByDepartmentWithPagination(ctx context.Context, departmentID uuid.UUID, status *string, offset, limit int) ([]entities.LetterIncomingDispositionDepartment, int64, error) { + db := DBFromContext(ctx, r.db) + query := db.WithContext(ctx).Where("department_id = ?", departmentID) + + if status != nil && *status != "" { + query = query.Where("status = ?", *status) + } + + var total int64 + if err := query.Model(&entities.LetterIncomingDispositionDepartment{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + var list []entities.LetterIncomingDispositionDepartment + if err := query. + Preload("Department"). + Preload("LetterIncoming"). + Preload("LetterIncomingDisposition.Department"). + Offset(offset). + Limit(limit). + Order("created_at DESC"). + Find(&list).Error; err != nil { + return nil, 0, err + } + + return list, total, nil +} + +func (r *LetterIncomingDispositionDepartmentRepository) GetByLetterIncomingID(ctx context.Context, letterIncomingID uuid.UUID) ([]entities.LetterIncomingDispositionDepartment, error) { + db := DBFromContext(ctx, r.db) + var list []entities.LetterIncomingDispositionDepartment + if err := db.WithContext(ctx). + Where("letter_incoming_id = ?", letterIncomingID). + Preload("Department"). + Preload("LetterIncoming"). + Preload("LetterIncomingDisposition.Department"). + Order("created_at DESC"). + Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func (r *LetterIncomingDispositionDepartmentRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status entities.LetterIncomingDispositionDepartmentStatus, notes string, readAt, completedAt *time.Time) error { + db := DBFromContext(ctx, r.db) + updates := map[string]interface{}{ + "status": status, + } + if readAt != nil { + updates["read_at"] = readAt + } + if completedAt != nil { + updates["completed_at"] = completedAt + } + + if notes != "" { + updates["notes"] = notes + } + return db.WithContext(ctx).Model(&entities.LetterIncomingDispositionDepartment{}). + Where("id = ?", id). + Updates(updates).Error +} + func (r *LetterIncomingDispositionDepartmentRepository) ListByDisposition(ctx context.Context, dispositionID uuid.UUID) ([]entities.LetterIncomingDispositionDepartment, error) { db := DBFromContext(ctx, r.db) var list []entities.LetterIncomingDispositionDepartment @@ -292,11 +605,63 @@ type LetterIncomingRecipientRepository struct{ db *gorm.DB } func NewLetterIncomingRecipientRepository(db *gorm.DB) *LetterIncomingRecipientRepository { return &LetterIncomingRecipientRepository{db: db} } +func (r *LetterIncomingRecipientRepository) DB(ctx context.Context) *gorm.DB { + return DBFromContext(ctx, r.db) +} + func (r *LetterIncomingRecipientRepository) CreateBulk(ctx context.Context, recs []entities.LetterIncomingRecipient) error { db := DBFromContext(ctx, r.db) return db.WithContext(ctx).Create(&recs).Error } +func (r *LetterIncomingRecipientRepository) Create(ctx context.Context, recipient *entities.LetterIncomingRecipient) error { + return r.DB(ctx).Create(recipient).Error +} + +func (r *LetterIncomingRecipientRepository) Update(ctx context.Context, recipient *entities.LetterIncomingRecipient) error { + return r.DB(ctx).Save(recipient).Error +} + +func (r *LetterIncomingRecipientRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.LetterIncomingRecipient, error) { + var recipient entities.LetterIncomingRecipient + if err := r.DB(ctx).Where("id = ?", id).First(&recipient).Error; err != nil { + return nil, err + } + return &recipient, nil +} + +func (r *LetterIncomingRecipientRepository) GetByLetterAndDepartment(ctx context.Context, letterID uuid.UUID, departmentID uuid.UUID) (*entities.LetterIncomingRecipient, error) { + var recipient entities.LetterIncomingRecipient + if err := r.DB(ctx).Where("letter_id = ? AND recipient_department_id = ?", letterID, departmentID).First(&recipient).Error; err != nil { + return nil, err + } + return &recipient, nil +} + +func (r *LetterIncomingRecipientRepository) GetByLetterAndUser(ctx context.Context, letterID uuid.UUID, userID uuid.UUID) (*entities.LetterIncomingRecipient, error) { + var recipient entities.LetterIncomingRecipient + if err := r.DB(ctx).Where("letter_id = ? AND recipient_user_id = ?", letterID, userID).First(&recipient).Error; err != nil { + return nil, err + } + return &recipient, nil +} + +func (r *LetterIncomingRecipientRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingRecipient, error) { + var recipients []entities.LetterIncomingRecipient + if err := r.DB(ctx).Where("letter_id = ?", letterID).Find(&recipients).Error; err != nil { + return nil, err + } + return recipients, nil +} + +func (r *LetterIncomingRecipientRepository) ListByDepartment(ctx context.Context, departmentID uuid.UUID) ([]entities.LetterIncomingRecipient, error) { + var recipients []entities.LetterIncomingRecipient + if err := r.DB(ctx).Where("recipient_department_id = ?", departmentID).Find(&recipients).Error; err != nil { + return nil, err + } + return recipients, nil +} + func (r *LetterIncomingRecipientRepository) GetLetterIDsByDepartment(ctx context.Context, departmentID uuid.UUID) ([]uuid.UUID, error) { db := DBFromContext(ctx, r.db) var letterIDs []uuid.UUID @@ -310,6 +675,60 @@ func (r *LetterIncomingRecipientRepository) GetLetterIDsByDepartment(ctx context return letterIDs, nil } +func (r *LetterIncomingRecipientRepository) CountReadByLetter(ctx context.Context, letterID uuid.UUID) (int, error) { + db := DBFromContext(ctx, r.db) + var count int64 + if err := db.WithContext(ctx). + Model(&entities.LetterIncomingRecipient{}). + Where("letter_id = ? AND read_at IS NOT NULL", letterID). + Count(&count).Error; err != nil { + return 0, err + } + return int(count), nil +} + +func (r *LetterIncomingRecipientRepository) CountUnreadByUser(ctx context.Context, userID uuid.UUID) (int, error) { + db := DBFromContext(ctx, r.db) + var count int64 + if err := db.WithContext(ctx). + Model(&entities.LetterIncomingRecipient{}). + Where("recipient_user_id = ? AND read_at IS NULL", userID). + Count(&count).Error; err != nil { + return 0, err + } + return int(count), nil +} + +func (r *LetterIncomingRecipientRepository) MarkAsRead(ctx context.Context, letterID, userID uuid.UUID) error { + db := DBFromContext(ctx, r.db) + now := time.Now() + return db.WithContext(ctx). + Model(&entities.LetterIncomingRecipient{}). + Where("letter_id = ? AND recipient_user_id = ?", letterID, userID). + Update("read_at", now).Error +} + +func (r *LetterIncomingRecipientRepository) GetByLetterIDsAndUser(ctx context.Context, letterIDs []uuid.UUID, userID uuid.UUID) (map[uuid.UUID]*entities.LetterIncomingRecipient, error) { + if len(letterIDs) == 0 { + return make(map[uuid.UUID]*entities.LetterIncomingRecipient), nil + } + + db := DBFromContext(ctx, r.db) + var recipients []entities.LetterIncomingRecipient + if err := db.WithContext(ctx). + Where("letter_id IN ? AND recipient_user_id = ?", letterIDs, userID). + Find(&recipients).Error; err != nil { + return nil, err + } + + result := make(map[uuid.UUID]*entities.LetterIncomingRecipient) + for i := range recipients { + result[recipients[i].LetterID] = &recipients[i] + } + + return result, nil +} + func (r *LetterIncomingRecipientRepository) HasDepartmentAccess(ctx context.Context, letterID uuid.UUID, departmentID uuid.UUID) (bool, error) { db := DBFromContext(ctx, r.db) var count int64 diff --git a/internal/repository/master_repository.go b/internal/repository/master_repository.go index 210060c..f7050f6 100644 --- a/internal/repository/master_repository.go +++ b/internal/repository/master_repository.go @@ -59,6 +59,24 @@ func (r *PriorityRepository) Get(ctx context.Context, id uuid.UUID) (*entities.P return &e, nil } +func (r *PriorityRepository) GetByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*entities.Priority, error) { + if len(ids) == 0 { + return make(map[uuid.UUID]*entities.Priority), nil + } + + var priorities []entities.Priority + if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&priorities).Error; err != nil { + return nil, err + } + + result := make(map[uuid.UUID]*entities.Priority) + for i := range priorities { + result[priorities[i].ID] = &priorities[i] + } + + return result, nil +} + type InstitutionRepository struct{ db *gorm.DB } func NewInstitutionRepository(db *gorm.DB) *InstitutionRepository { @@ -100,6 +118,24 @@ func (r *InstitutionRepository) Get(ctx context.Context, id uuid.UUID) (*entitie return &e, nil } +func (r *InstitutionRepository) GetByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*entities.Institution, error) { + if len(ids) == 0 { + return make(map[uuid.UUID]*entities.Institution), nil + } + + var institutions []entities.Institution + if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&institutions).Error; err != nil { + return nil, err + } + + result := make(map[uuid.UUID]*entities.Institution) + for i := range institutions { + result[institutions[i].ID] = &institutions[i] + } + + return result, nil +} + type DispositionActionRepository struct{ db *gorm.DB } func NewDispositionActionRepository(db *gorm.DB) *DispositionActionRepository { @@ -188,3 +224,12 @@ func (r *DepartmentRepository) List(ctx context.Context, search string, limit, o return list, total, nil } + +func (r *DepartmentRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.Department, error) { + db := DBFromContext(ctx, r.db) + var e entities.Department + if err := db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil { + return nil, err + } + return &e, nil +} diff --git a/internal/repository/user_department_repository.go b/internal/repository/user_department_repository.go index cb06aa4..7fafe4a 100644 --- a/internal/repository/user_department_repository.go +++ b/internal/repository/user_department_repository.go @@ -13,15 +13,14 @@ func NewUserDepartmentRepository(db *gorm.DB) *UserDepartmentRepository { return &UserDepartmentRepository{db: db} } -type userDepartmentRow struct { +type UserDepartmentRow struct { UserID uuid.UUID `gorm:"column:user_id"` DepartmentID uuid.UUID `gorm:"column:department_id"` } -// ListActiveByDepartmentIDs returns active user-department memberships for given department IDs. -func (r *UserDepartmentRepository) ListActiveByDepartmentIDs(ctx context.Context, departmentIDs []uuid.UUID) ([]userDepartmentRow, error) { +func (r *UserDepartmentRepository) ListActiveByDepartmentIDs(ctx context.Context, departmentIDs []uuid.UUID) ([]UserDepartmentRow, error) { db := DBFromContext(ctx, r.db) - rows := make([]userDepartmentRow, 0) + rows := make([]UserDepartmentRow, 0) if len(departmentIDs) == 0 { return rows, nil } diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index 412c74f..d107eda 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -35,6 +35,18 @@ func (r *UserRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entiti return &user, nil } +// GetByIDLight retrieves user without preloading relationships - for faster auth checks +func (r *UserRepositoryImpl) GetByIDLight(ctx context.Context, id uuid.UUID) (*entities.User, error) { + var user entities.User + err := r.b.WithContext(ctx). + Where("id = ?", id). + First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*entities.User, error) { var user entities.User err := r.b.WithContext(ctx). diff --git a/internal/router/health_handler.go b/internal/router/health_handler.go index 5aa2b14..1b01b3d 100644 --- a/internal/router/health_handler.go +++ b/internal/router/health_handler.go @@ -67,12 +67,18 @@ type LetterHandler interface { CreateIncomingLetter(c *gin.Context) GetIncomingLetter(c *gin.Context) ListIncomingLetters(c *gin.Context) + GetLetterUnreadCounts(c *gin.Context) + MarkIncomingLetterAsRead(c *gin.Context) + MarkOutgoingLetterAsRead(c *gin.Context) UpdateIncomingLetter(c *gin.Context) DeleteIncomingLetter(c *gin.Context) + BulkArchiveIncomingLetters(c *gin.Context) CreateDispositions(c *gin.Context) - //ListDispositionsByLetter(c *gin.Context) GetEnhancedDispositionsByLetter(c *gin.Context) + GetDepartmentDispositionStatus(c *gin.Context) + UpdateDispositionStatus(c *gin.Context) + GetLetterCTA(c *gin.Context) CreateDiscussion(c *gin.Context) UpdateDiscussion(c *gin.Context) @@ -103,9 +109,10 @@ type LetterOutgoingHandler interface { CreateDiscussion(c *gin.Context) UpdateDiscussion(c *gin.Context) DeleteDiscussion(c *gin.Context) - + GetApprovalDiscussions(c *gin.Context) GetApprovalTimeline(c *gin.Context) + BulkArchiveOutgoingLetters(c *gin.Context) } type AdminApprovalFlowHandler interface { @@ -122,10 +129,13 @@ type AdminApprovalFlowHandler interface { type DispositionRouteHandler interface { Create(c *gin.Context) + BulkCreateOrUpdate(c *gin.Context) Update(c *gin.Context) Get(c *gin.Context) ListByFromDept(c *gin.Context) SetActive(c *gin.Context) + ListGrouped(c *gin.Context) + ListAll(c *gin.Context) } type OnlyOfficeHandler interface { @@ -146,3 +156,13 @@ type AnalyticsHandler interface { GetMonthlyTrend(c *gin.Context) GetApprovalMetrics(c *gin.Context) } + +type NotificationHandler interface { + TriggerNotification(c *gin.Context) + BulkTriggerNotification(c *gin.Context) + GetSubscriber(c *gin.Context) + UpdateSubscriberChannel(c *gin.Context) + TriggerNotificationForCurrentUser(c *gin.Context) + GetCurrentUserSubscriber(c *gin.Context) + UpdateCurrentUserSubscriberChannel(c *gin.Context) +} diff --git a/internal/router/router.go b/internal/router/router.go index 04d4f74..74e61a6 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -22,6 +22,7 @@ type Router struct { dispRouteHandler DispositionRouteHandler onlyOfficeHandler OnlyOfficeHandler analyticsHandler AnalyticsHandler + notificationHandler NotificationHandler } func NewRouter( @@ -39,6 +40,7 @@ func NewRouter( dispRouteHandler DispositionRouteHandler, onlyOfficeHandler OnlyOfficeHandler, analyticsHandler AnalyticsHandler, + notificationHandler NotificationHandler, ) *Router { return &Router{ config: cfg, @@ -55,6 +57,7 @@ func NewRouter( dispRouteHandler: dispRouteHandler, onlyOfficeHandler: onlyOfficeHandler, analyticsHandler: analyticsHandler, + notificationHandler: notificationHandler, } } @@ -89,7 +92,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { users := v1.Group("/users") users.Use(r.authMiddleware.RequireAuth()) { - users.GET("", r.authMiddleware.RequirePermissions("user.read"), r.userHandler.ListUsers) + users.GET("", r.userHandler.ListUsers) users.GET("/profile", r.userHandler.GetProfile) users.GET("/:id/profile", r.userHandler.GetUserProfile) users.PUT("/profile", r.userHandler.UpdateProfile) @@ -156,16 +159,22 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { lettersch := v1.Group("/letters") lettersch.Use(r.authMiddleware.RequireAuth()) { - lettersch.POST("/incoming", r.letterHandler.CreateIncomingLetter) - lettersch.GET("/incoming/:id", r.letterHandler.GetIncomingLetter) + lettersch.GET("/unread-counts", r.letterHandler.GetLetterUnreadCounts) + lettersch.GET("/incoming", r.letterHandler.ListIncomingLetters) + lettersch.POST("/incoming", r.letterHandler.CreateIncomingLetter) + lettersch.GET("/incoming/cta/:letter_id", r.letterHandler.GetLetterCTA) + lettersch.GET("/incoming/:id", r.letterHandler.GetIncomingLetter) lettersch.PUT("/incoming/:id", r.letterHandler.UpdateIncomingLetter) + lettersch.PUT("/incoming/:id/read", r.letterHandler.MarkIncomingLetterAsRead) lettersch.DELETE("/incoming/:id", r.letterHandler.DeleteIncomingLetter) + lettersch.POST("/incoming/archive", r.letterHandler.BulkArchiveIncomingLetters) lettersch.POST("/outgoing", r.letterOutgoingHandler.CreateOutgoingLetter) lettersch.GET("/outgoing/:id", r.letterOutgoingHandler.GetOutgoingLetter) lettersch.GET("/outgoing", r.letterOutgoingHandler.ListOutgoingLetters) lettersch.PUT("/outgoing/:id", r.letterOutgoingHandler.UpdateOutgoingLetter) + lettersch.PUT("/outgoing/:id/read", r.letterHandler.MarkOutgoingLetterAsRead) lettersch.DELETE("/outgoing/:id", r.letterOutgoingHandler.DeleteOutgoingLetter) lettersch.POST("/outgoing/:id/submit", r.letterOutgoingHandler.SubmitForApproval) @@ -173,6 +182,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { lettersch.POST("/outgoing/:id/reject", r.letterOutgoingHandler.RejectOutgoingLetter) lettersch.POST("/outgoing/:id/send", r.letterOutgoingHandler.SendOutgoingLetter) lettersch.POST("/outgoing/:id/archive", r.letterOutgoingHandler.ArchiveOutgoingLetter) + lettersch.POST("/outgoing/archive", r.letterOutgoingHandler.BulkArchiveOutgoingLetters) lettersch.GET("/outgoing/:id/cta", r.letterOutgoingHandler.GetLetterApprovalInfo) lettersch.GET("/outgoing/:id/approvals", r.letterOutgoingHandler.GetLetterApprovals) @@ -192,6 +202,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { lettersch.POST("/dispositions/:letter_id", r.letterHandler.CreateDispositions) lettersch.GET("/dispositions/:letter_id", r.letterHandler.GetEnhancedDispositionsByLetter) + lettersch.GET("/dispositions/:letter_id/department/status", r.letterHandler.GetDepartmentDispositionStatus) + lettersch.PUT("/dispositions/:letter_id/status", r.letterHandler.UpdateDispositionStatus) lettersch.POST("/discussions/:letter_id", r.letterHandler.CreateDiscussion) lettersch.PUT("/discussions/:letter_id/:discussion_id", r.letterHandler.UpdateDiscussion) @@ -200,11 +212,14 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { droutes := v1.Group("/disposition-routes") droutes.Use(r.authMiddleware.RequireAuth()) { - droutes.POST("", r.dispRouteHandler.Create) - droutes.GET(":id", r.dispRouteHandler.Get) - droutes.PUT(":id", r.dispRouteHandler.Update) - droutes.GET("department", r.dispRouteHandler.ListByFromDept) - droutes.PUT(":id/active", r.dispRouteHandler.SetActive) + droutes.POST("", r.dispRouteHandler.Create) // Create with upsert logic + droutes.PUT("/bulk", r.dispRouteHandler.BulkCreateOrUpdate) // Explicit bulk create/update + droutes.GET("", r.dispRouteHandler.ListAll) // List all routes with details + droutes.GET("/grouped", r.dispRouteHandler.ListGrouped) // List grouped by from_department_id + droutes.GET("/department", r.dispRouteHandler.ListByFromDept) + droutes.GET("/:id", r.dispRouteHandler.Get) + droutes.PUT("/:id", r.dispRouteHandler.Update) + droutes.PUT("/:id/active", r.dispRouteHandler.SetActive) } admin := v1.Group("/setting") @@ -224,13 +239,10 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { } } - // OnlyOffice routes onlyoffice := v1.Group("/onlyoffice") { - // Callback endpoint - no auth required (OnlyOffice will call this) onlyoffice.POST("/callback/:key", r.onlyOfficeHandler.ProcessCallback) - // Protected endpoints onlyofficeAuth := onlyoffice.Group("") onlyofficeAuth.Use(r.authMiddleware.RequireAuth()) { @@ -242,7 +254,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { } } - // Analytics routes analytics := v1.Group("/analytics") analytics.Use(r.authMiddleware.RequireAuth()) { @@ -254,5 +265,22 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { analytics.GET("/monthly-trend", r.analyticsHandler.GetMonthlyTrend) analytics.GET("/approval-metrics", r.analyticsHandler.GetApprovalMetrics) } + + notifications := v1.Group("/notifications") + notifications.Use(r.authMiddleware.RequireAuth()) + { + notifications.POST("/me/trigger", r.notificationHandler.TriggerNotificationForCurrentUser) + notifications.GET("/me/subscriber", r.notificationHandler.GetCurrentUserSubscriber) + notifications.PUT("/me/channel", r.notificationHandler.UpdateCurrentUserSubscriberChannel) + + notifAdmin := notifications.Group("") + notifAdmin.Use(r.authMiddleware.RequirePermissions("notification.admin")) + { + notifAdmin.POST("/trigger", r.notificationHandler.TriggerNotification) + notifAdmin.POST("/bulk-trigger", r.notificationHandler.BulkTriggerNotification) + notifAdmin.GET("/subscribers/:userId", r.notificationHandler.GetSubscriber) + notifAdmin.PUT("/subscribers/channel", r.notificationHandler.UpdateSubscriberChannel) + } + } } } diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index c544ba6..eba0c4e 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -90,7 +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 + // Note: Departments are not loaded in light version, add if needed return userResponse, nil } diff --git a/internal/service/disposition_route_service.go b/internal/service/disposition_route_service.go index 21f43d8..b70a38f 100644 --- a/internal/service/disposition_route_service.go +++ b/internal/service/disposition_route_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "sort" "eslogad-be/internal/contract" "eslogad-be/internal/entities" @@ -19,19 +20,95 @@ func NewDispositionRouteService(repo *repository.DispositionRouteRepository) *Di return &DispositionRouteServiceImpl{repo: repo} } -func (s *DispositionRouteServiceImpl) Create(ctx context.Context, req *contract.CreateDispositionRouteRequest) (*contract.DispositionRouteResponse, error) { - entity := &entities.DispositionRoute{FromDepartmentID: req.FromDepartmentID, ToDepartmentID: req.ToDepartmentID} +// CreateOrUpdate handles bulk create or update of disposition routes +func (s *DispositionRouteServiceImpl) CreateOrUpdate(ctx context.Context, req *contract.CreateDispositionRouteRequest) (*contract.BulkCreateDispositionRouteResponse, error) { + // Set default values + isActive := true if req.IsActive != nil { - entity.IsActive = *req.IsActive + isActive = *req.IsActive } + + var allowedActions entities.JSONB if req.AllowedActions != nil { - entity.AllowedActions = entities.JSONB(*req.AllowedActions) + allowedActions = entities.JSONB(*req.AllowedActions) } - if err := s.repo.Create(ctx, entity); err != nil { + + // Perform bulk upsert + created, updated, err := s.repo.BulkUpsert(ctx, req.FromDepartmentID, req.ToDepartmentIDs, isActive, allowedActions) + if err != nil { return nil, err } - resp := transformer.DispositionRoutesToContract([]entities.DispositionRoute{*entity})[0] - return &resp, nil + + // Fetch all routes for the from_department_id to return + routes, err := s.repo.ListByFromDept(ctx, req.FromDepartmentID) + if err != nil { + return nil, err + } + + // Transform to response + routeResponses := transformer.DispositionRoutesToContract(routes) + + return &contract.BulkCreateDispositionRouteResponse{ + Created: created, + Updated: updated, + Routes: routeResponses, + }, nil +} + +// Create maintains backward compatibility for single route creation +func (s *DispositionRouteServiceImpl) Create(ctx context.Context, req *contract.CreateDispositionRouteRequest) (*contract.DispositionRouteResponse, error) { + // If only one to_department_id is provided, create a single route + if len(req.ToDepartmentIDs) == 1 { + entity := &entities.DispositionRoute{ + FromDepartmentID: req.FromDepartmentID, + ToDepartmentID: req.ToDepartmentIDs[0], + } + if req.IsActive != nil { + entity.IsActive = *req.IsActive + } else { + entity.IsActive = true + } + if req.AllowedActions != nil { + entity.AllowedActions = entities.JSONB(*req.AllowedActions) + } + + // Use upsert to handle create or update + if err := s.repo.Upsert(ctx, entity); err != nil { + return nil, err + } + + // Fetch the created/updated route + route, err := s.repo.Get(ctx, entity.ID) + if err != nil { + // If we can't get by ID (new creation), try to get by from/to combination + routes, err := s.repo.ListByFromDept(ctx, req.FromDepartmentID) + if err != nil { + return nil, err + } + for _, r := range routes { + if r.ToDepartmentID == req.ToDepartmentIDs[0] { + resp := transformer.DispositionRoutesToContract([]entities.DispositionRoute{r})[0] + return &resp, nil + } + } + } + + resp := transformer.DispositionRoutesToContract([]entities.DispositionRoute{*route})[0] + return &resp, nil + } + + // For multiple to_department_ids, use bulk create/update + bulkResp, err := s.CreateOrUpdate(ctx, req) + if err != nil { + return nil, err + } + + // Return the first route as response for backward compatibility + if len(bulkResp.Routes) > 0 { + return &bulkResp.Routes[0], nil + } + + return nil, nil } func (s *DispositionRouteServiceImpl) Update(ctx context.Context, id uuid.UUID, req *contract.UpdateDispositionRouteRequest) (*contract.DispositionRouteResponse, error) { entity, err := s.repo.Get(ctx, id) @@ -68,3 +145,79 @@ func (s *DispositionRouteServiceImpl) ListByFromDept(ctx context.Context, from u func (s *DispositionRouteServiceImpl) SetActive(ctx context.Context, id uuid.UUID, active bool) error { return s.repo.SetActive(ctx, id, active) } + +// ListGrouped returns all disposition routes grouped by from_department_id with clean department structure +func (s *DispositionRouteServiceImpl) ListGrouped(ctx context.Context) (*contract.ListDispositionRoutesGroupedResponse, error) { + // Get routes with department details + routes, err := s.repo.ListAllGroupedWithDepartments(ctx) + if err != nil { + return nil, err + } + + // Group routes by from_department_id and collect department info + type groupedData struct { + fromDept contract.DepartmentMapping + toDepts []contract.DepartmentMapping + toDeptMap map[uuid.UUID]bool // To avoid duplicates + } + + grouped := make(map[uuid.UUID]*groupedData) + + for _, route := range routes { + if _, exists := grouped[route.FromDepartmentID]; !exists { + grouped[route.FromDepartmentID] = &groupedData{ + fromDept: contract.DepartmentMapping{ + ID: route.FromDepartmentID, + Name: route.FromDepartment.Name, + }, + toDepts: []contract.DepartmentMapping{}, + toDeptMap: make(map[uuid.UUID]bool), + } + } + + // Add to_department if not already added (avoid duplicates) + if !grouped[route.FromDepartmentID].toDeptMap[route.ToDepartmentID] { + grouped[route.FromDepartmentID].toDepts = append( + grouped[route.FromDepartmentID].toDepts, + contract.DepartmentMapping{ + ID: route.ToDepartmentID, + Name: route.ToDepartment.Name, + }, + ) + grouped[route.FromDepartmentID].toDeptMap[route.ToDepartmentID] = true + } + } + + // Convert to response format + var dispositions []contract.DispositionRouteGroupedItem + for _, data := range grouped { + dispositions = append(dispositions, contract.DispositionRouteGroupedItem{ + FromDepartment: data.fromDept, + ToDepartments: data.toDepts, + }) + } + + // Sort by from department name for consistent ordering + sort.Slice(dispositions, func(i, j int) bool { + return dispositions[i].FromDepartment.Name < dispositions[j].FromDepartment.Name + }) + + return &contract.ListDispositionRoutesGroupedResponse{ + Dispositions: dispositions, + }, nil +} + +// ListAll returns all disposition routes with department details +func (s *DispositionRouteServiceImpl) ListAll(ctx context.Context) (*contract.ListDispositionRoutesDetailedResponse, error) { + routes, err := s.repo.ListAll(ctx) + if err != nil { + return nil, err + } + + routeResponses := transformer.DispositionRoutesToContract(routes) + + return &contract.ListDispositionRoutesDetailedResponse{ + Routes: routeResponses, + Total: len(routeResponses), + }, nil +} diff --git a/internal/service/file_service.go b/internal/service/file_service.go index a7bc4c1..25cf7e8 100644 --- a/internal/service/file_service.go +++ b/internal/service/file_service.go @@ -32,8 +32,8 @@ func (s *FileServiceImpl) UploadProfileAvatar(ctx context.Context, userID uuid.U return "", err } ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) - if ext := mimeExtFromContentType(contentType); ext != "" { - ext = ext + if mimeExt := mimeExtFromContentType(contentType); mimeExt != "" { + ext = mimeExt } key := buildObjectKey("profile", userID, ext) url, err := s.storage.Upload(ctx, s.profileBucket, key, content, contentType) @@ -50,8 +50,8 @@ func (s *FileServiceImpl) UploadDocument(ctx context.Context, userID uuid.UUID, return "", "", err } ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) - if ext := mimeExtFromContentType(contentType); ext != "" { - ext = ext + if mimeExt := mimeExtFromContentType(contentType); mimeExt != "" { + ext = mimeExt } key := buildObjectKey("documents", userID, ext) url, err := s.storage.Upload(ctx, s.docBucket, key, content, contentType) diff --git a/internal/service/letter_outgoing_service.go b/internal/service/letter_outgoing_service.go index 0453734..99f58d3 100644 --- a/internal/service/letter_outgoing_service.go +++ b/internal/service/letter_outgoing_service.go @@ -114,6 +114,11 @@ func (s *LetterOutgoingServiceImpl) GetOutgoingLetterByID(ctx context.Context, i } func (s *LetterOutgoingServiceImpl) ListOutgoingLetters(ctx context.Context, req *contract.ListOutgoingLettersRequest) (*contract.ListOutgoingLettersResponse, error) { + // Extract user context from gin context + appCtx := appcontext.FromGinContext(ctx) + userID := appCtx.UserID + departmentID := appCtx.DepartmentID + offset := (req.Page - 1) * req.Limit if offset < 0 { offset = 0 @@ -124,6 +129,11 @@ func (s *LetterOutgoingServiceImpl) ListOutgoingLetters(ctx context.Context, req DepartmentID: req.DepartmentID, ReceiverInstitutionID: req.ReceiverInstitutionID, PriorityID: req.PriorityID, + UserID: &userID, + } + + if departmentID != uuid.Nil { + filter.DepartmentID = &departmentID } if req.Status != "" { @@ -152,16 +162,104 @@ func (s *LetterOutgoingServiceImpl) ListOutgoingLetters(ctx context.Context, req } } - // Apply access control overrides based on user context - ApplyLetterFilterOverrides(ctx, &filter) + filter.IsArchived = req.IsArchived + // Get raw letters data letters, total, err := s.processor.ListOutgoingLetters(ctx, filter, req.Limit, offset) if err != nil { return nil, err } + // Collect IDs for batch loading + letterIDs := make([]uuid.UUID, len(letters)) + priorityIDs := make(map[uuid.UUID]bool) + institutionIDs := make(map[uuid.UUID]bool) + + for i, letter := range letters { + letterIDs[i] = letter.ID + if letter.PriorityID != nil { + priorityIDs[*letter.PriorityID] = true + } + if letter.ReceiverInstitutionID != nil { + institutionIDs[*letter.ReceiverInstitutionID] = true + } + } + + // Convert maps to slices + priorityIDSlice := make([]uuid.UUID, 0, len(priorityIDs)) + for id := range priorityIDs { + priorityIDSlice = append(priorityIDSlice, id) + } + + institutionIDSlice := make([]uuid.UUID, 0, len(institutionIDs)) + for id := range institutionIDs { + institutionIDSlice = append(institutionIDSlice, id) + } + + // Parallel batch loading + type batchResult struct { + attachments map[uuid.UUID][]entities.LetterOutgoingAttachment + recipients map[uuid.UUID][]entities.LetterOutgoingRecipient + priorities map[uuid.UUID]*entities.Priority + institutions map[uuid.UUID]*entities.Institution + err error + } + + result := batchResult{} + errChan := make(chan error, 4) + + // Load attachments + go func() { + result.attachments, err = s.processor.GetBatchAttachments(ctx, letterIDs) + errChan <- err + }() + + // Load recipients + go func() { + result.recipients, err = s.processor.GetBatchRecipients(ctx, letterIDs) + errChan <- err + }() + + // Load priorities + go func() { + result.priorities, err = s.processor.GetBatchPriorities(ctx, priorityIDSlice) + errChan <- err + }() + + // Load institutions + go func() { + result.institutions, err = s.processor.GetBatchInstitutions(ctx, institutionIDSlice) + errChan <- err + }() + + // Wait for all goroutines and check for errors + for i := 0; i < 4; i++ { + if err := <-errChan; err != nil { + return nil, err + } + } + + // Transform letters with batch loaded data items := make([]*contract.OutgoingLetterResponse, len(letters)) for i, letter := range letters { + // Attach batch loaded data to letter + if attachments, ok := result.attachments[letter.ID]; ok { + letter.Attachments = attachments + } + if recipients, ok := result.recipients[letter.ID]; ok { + letter.Recipients = recipients + } + if letter.PriorityID != nil { + if priority, ok := result.priorities[*letter.PriorityID]; ok { + letter.Priority = priority + } + } + if letter.ReceiverInstitutionID != nil { + if institution, ok := result.institutions[*letter.ReceiverInstitutionID]; ok { + letter.ReceiverInstitution = institution + } + } + items[i] = transformLetterToResponse(&letter) } @@ -1361,3 +1459,16 @@ func ApplyLetterFilterOverrides(ctx context.Context, filter *repository.ListOutg filter.UserID = &appCtx.UserID } } + +func (s *LetterOutgoingServiceImpl) BulkArchiveOutgoingLetters(ctx context.Context, letterIDs []uuid.UUID) (*contract.BulkArchiveLettersResponse, error) { + archivedCount, err := s.processor.BulkArchiveOutgoingLetters(ctx, letterIDs) + if err != nil { + return nil, err + } + + return &contract.BulkArchiveLettersResponse{ + Success: true, + Message: "Letters archived successfully", + ArchivedCount: int(archivedCount), + }, nil +} diff --git a/internal/service/letter_service.go b/internal/service/letter_service.go index 165c260..bc0bf0f 100644 --- a/internal/service/letter_service.go +++ b/internal/service/letter_service.go @@ -2,42 +2,418 @@ package service import ( "context" + "eslogad-be/internal/logger" + "time" + "eslogad-be/internal/appcontext" + "eslogad-be/internal/constant" "eslogad-be/internal/contract" + "eslogad-be/internal/entities" + "eslogad-be/internal/processor" + "eslogad-be/internal/repository" + "eslogad-be/internal/transformer" "github.com/google/uuid" ) +const ( + DefaultIncomingLetterID = "ESLI" +) + type LetterProcessor interface { CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) GetIncomingLetterByID(ctx context.Context, id uuid.UUID) (*contract.IncomingLetterResponse, error) - ListIncomingLetters(ctx context.Context, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error) + ListIncomingLetters(ctx context.Context, filter repository.ListIncomingLettersFilter, page, limit int) ([]entities.LetterIncoming, int64, error) + GetLetterUnreadCounts(ctx context.Context) (*contract.LetterUnreadCountResponse, error) + MarkIncomingLetterAsRead(ctx context.Context, letterID uuid.UUID) (*contract.MarkLetterReadResponse, error) + MarkOutgoingLetterAsRead(ctx context.Context, letterID uuid.UUID) (*contract.MarkLetterReadResponse, error) UpdateIncomingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error + BulkArchiveIncomingLetters(ctx context.Context, letterIDs []uuid.UUID) (int64, error) + BulkArchiveIncomingLettersForUser(ctx context.Context, letterIDs []uuid.UUID, userID uuid.UUID) (int64, error) + + // Batch loading methods + GetBatchAttachments(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterIncomingAttachment, error) + GetBatchPriorities(ctx context.Context, priorityIDs []uuid.UUID) (map[uuid.UUID]*entities.Priority, error) + GetBatchInstitutions(ctx context.Context, institutionIDs []uuid.UUID) (map[uuid.UUID]*entities.Institution, error) + GetBatchRecipientsByUser(ctx context.Context, letterIDs []uuid.UUID, userID uuid.UUID) (map[uuid.UUID]*entities.LetterIncomingRecipient, error) + CountUnreadByUser(ctx context.Context, userID uuid.UUID) (int, error) CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) + + GetDepartmentDispositionStatus(ctx context.Context, req *contract.GetDepartmentDispositionStatusRequest) (*contract.ListDepartmentDispositionStatusResponse, error) + UpdateDispositionStatus(ctx context.Context, req *contract.UpdateDispositionStatusRequest) (*contract.DepartmentDispositionStatusResponse, error) + + GetLetterCTA(ctx context.Context, letterID uuid.UUID, departmentID uuid.UUID) (*contract.LetterCTAResponse, error) } type LetterServiceImpl struct { - processor LetterProcessor + processor LetterProcessor + txManager *repository.TxManager + numberGenerator NumberGenerator + recipientProcessor RecipientProcessor + activityLogger ActivityLogger + letterDispositionProcessor LetterDispositionProcessor + notificationProcessor processor.NotificationProcessor } -func NewLetterService(processor LetterProcessor) *LetterServiceImpl { - return &LetterServiceImpl{processor: processor} +type NumberGenerator interface { + GenerateNumber(ctx context.Context, prefixKey, sequenceKey, defaultPrefix string) (string, error) +} + +type RecipientProcessor interface { + CreateDefaultRecipients(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingRecipient, error) + CreateSingleRecipient(ctx context.Context, recipient *entities.LetterIncomingRecipient) error +} + +type ActivityLogger interface { + LogLetterCreated(ctx context.Context, letterID uuid.UUID, userID uuid.UUID, letterNumber string) error + LogAttachmentUploaded(ctx context.Context, letterID uuid.UUID, userID uuid.UUID, fileName string, fileType string) error + LogLetterDispositionStatusUpdate(ctx context.Context, letterID uuid.UUID, userID uuid.UUID, status string) error +} + +type LetterDispositionProcessor interface { + CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) +} + +func NewLetterService( + processor LetterProcessor, + txManager *repository.TxManager, + numberGenerator NumberGenerator, + recipientProcessor RecipientProcessor, + activityLogger ActivityLogger, + letterDispositionProcessor LetterDispositionProcessor, + notificationProcessor processor.NotificationProcessor, +) *LetterServiceImpl { + return &LetterServiceImpl{ + processor: processor, + txManager: txManager, + numberGenerator: numberGenerator, + recipientProcessor: recipientProcessor, + activityLogger: activityLogger, + letterDispositionProcessor: letterDispositionProcessor, + notificationProcessor: notificationProcessor, + } } func (s *LetterServiceImpl) CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) { - return s.processor.CreateIncomingLetter(ctx, req) + var result *contract.IncomingLetterResponse + var recipients []entities.LetterIncomingRecipient + + err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { + letterNumber, err := s.generateLetterNumber(txCtx) + if err != nil { + return err + } + req.LetterNumber = letterNumber + + result, err = s.processor.CreateIncomingLetter(txCtx, req) + if err != nil { + return err + } + + recipients, err = s.createDefaultRecipients(txCtx, result.ID) + if err != nil { + return err + } + + if err := s.createDispositionsForRecipients(txCtx, result.ID, recipients); err != nil { + return err + } + + s.logLetterCreation(txCtx, result.ID, letterNumber) + + return nil + }) + + if err != nil { + return nil, err + } + + // Send notifications to all recipients after successful creation + if s.notificationProcessor != nil && len(recipients) > 0 { + go s.sendLetterNotifications(context.Background(), result, recipients) + } + + return result, nil } + +func (s *LetterServiceImpl) generateLetterNumber(ctx context.Context) (string, error) { + return s.numberGenerator.GenerateNumber( + ctx, + contract.SettingIncomingLetterPrefix, + contract.SettingIncomingLetterSequence, + DefaultIncomingLetterID, + ) +} + +func (s *LetterServiceImpl) createDefaultRecipients(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingRecipient, error) { + return s.recipientProcessor.CreateDefaultRecipients(ctx, letterID) +} + +func (s *LetterServiceImpl) createDispositionsForRecipients(ctx context.Context, letterID uuid.UUID, recipients []entities.LetterIncomingRecipient) error { + if len(recipients) == 0 || s.letterDispositionProcessor == nil { + return nil + } + + departmentIDs := s.extractUniqueDepartmentIDs(recipients) + if len(departmentIDs) == 0 { + return nil + } + + systemDeptID := constant.SystemDepartmentID + systemUserID := constant.SystemUserID + + dispositionReq := &contract.CreateLetterDispositionRequest{ + FromDepartment: systemDeptID, + LetterID: letterID, + ToDepartmentIDs: departmentIDs, + Notes: nil, + CreatedBy: systemUserID, + } + + _, err := s.letterDispositionProcessor.CreateDispositions(ctx, dispositionReq) + return err +} + +func (s *LetterServiceImpl) extractUniqueDepartmentIDs(recipients []entities.LetterIncomingRecipient) []uuid.UUID { + deptMap := make(map[uuid.UUID]bool) + var departmentIDs []uuid.UUID + + for _, recipient := range recipients { + if recipient.RecipientDepartmentID != nil && !deptMap[*recipient.RecipientDepartmentID] { + deptMap[*recipient.RecipientDepartmentID] = true + departmentIDs = append(departmentIDs, *recipient.RecipientDepartmentID) + } + } + + return departmentIDs +} + +func (s *LetterServiceImpl) logLetterCreation(ctx context.Context, letterID uuid.UUID, letterNumber string) { + if s.activityLogger == nil { + return + } + + userID := appcontext.FromGinContext(ctx).UserID + err := s.activityLogger.LogLetterCreated(ctx, letterID, userID, letterNumber) + if err != nil { + logger.FromContext(ctx).Error("error when insert into log", err) + } +} + +func (s *LetterServiceImpl) addCreatorAsRecipient(ctx context.Context, letterID uuid.UUID, creatorID uuid.UUID) (*entities.LetterIncomingRecipient, error) { + // Check if creator is already a recipient (to avoid duplicates) + existingRecipients, err := s.processor.GetBatchRecipientsByUser(ctx, []uuid.UUID{letterID}, creatorID) + if err != nil { + return nil, err + } + + // If creator is already a recipient, skip + if _, exists := existingRecipients[letterID]; exists { + return nil, nil + } + + // Create recipient entry for the creator + recipient := entities.LetterIncomingRecipient{ + ID: uuid.New(), + LetterID: letterID, + RecipientUserID: &creatorID, + Status: entities.RecipientStatusNew, + CreatedAt: time.Now(), + } + + // Save the recipient + if err := s.recipientProcessor.CreateSingleRecipient(ctx, &recipient); err != nil { + // Log error but don't fail the whole operation + logger.FromContext(ctx).Error("failed to add creator as recipient", err) + return nil, err + } + + return &recipient, nil +} + +func (s *LetterServiceImpl) sendLetterNotifications(ctx context.Context, letter *contract.IncomingLetterResponse, recipients []entities.LetterIncomingRecipient) { + for _, recipient := range recipients { + // Only send notification to user recipients (not department recipients) + // Also exclude the creator from receiving notifications + if recipient.RecipientUserID != nil && *recipient.RecipientUserID != letter.CreatedBy { + // Use description if available, otherwise use subject + err := s.notificationProcessor.SendIncomingLetterNotification( + ctx, + letter.ID, + *recipient.RecipientUserID, + "Surat Masuk", + letter.Subject) + + if err != nil { + // Log error but don't fail the entire operation + logger.FromContext(ctx).Error("failed to send notification", err) + } + } + } +} + func (s *LetterServiceImpl) GetIncomingLetterByID(ctx context.Context, id uuid.UUID) (*contract.IncomingLetterResponse, error) { return s.processor.GetIncomingLetterByID(ctx, id) } func (s *LetterServiceImpl) ListIncomingLetters(ctx context.Context, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error) { - return s.processor.ListIncomingLetters(ctx, req) + appCtx := appcontext.FromGinContext(ctx) + userID := appCtx.UserID + departmentID := appCtx.DepartmentID + + filter := repository.ListIncomingLettersFilter{ + Status: req.Status, + Query: req.Query, + DepartmentID: &departmentID, + UserID: &userID, + IsRead: req.IsRead, + PriorityIDs: req.PriorityIDs, + IsDispositioned: req.IsDispositioned, + IsArchived: req.IsArchived, + } + + letters, total, err := s.processor.ListIncomingLetters(ctx, filter, req.Page, req.Limit) + if err != nil { + return nil, err + } + + if len(letters) == 0 { + return &contract.ListIncomingLettersResponse{ + Letters: []contract.IncomingLetterResponse{}, + Pagination: transformer.CreatePaginationResponse(int(total), req.Page, req.Limit), + TotalUnread: 0, + }, nil + } + + letterIDs := make([]uuid.UUID, 0, len(letters)) + priorityIDSet := make(map[uuid.UUID]bool) + institutionIDSet := make(map[uuid.UUID]bool) + + for _, letter := range letters { + letterIDs = append(letterIDs, letter.ID) + if letter.PriorityID != nil { + priorityIDSet[*letter.PriorityID] = true + } + if letter.SenderInstitutionID != nil { + institutionIDSet[*letter.SenderInstitutionID] = true + } + } + + priorityIDs := make([]uuid.UUID, 0, len(priorityIDSet)) + for id := range priorityIDSet { + priorityIDs = append(priorityIDs, id) + } + + institutionIDs := make([]uuid.UUID, 0, len(institutionIDSet)) + for id := range institutionIDSet { + institutionIDs = append(institutionIDs, id) + } + + type batchResult struct { + attachments map[uuid.UUID][]entities.LetterIncomingAttachment + priorities map[uuid.UUID]*entities.Priority + institutions map[uuid.UUID]*entities.Institution + recipients map[uuid.UUID]*entities.LetterIncomingRecipient + err error + } + + resultChan := make(chan batchResult, 1) + + go func() { + result := batchResult{ + attachments: make(map[uuid.UUID][]entities.LetterIncomingAttachment), + priorities: make(map[uuid.UUID]*entities.Priority), + institutions: make(map[uuid.UUID]*entities.Institution), + recipients: make(map[uuid.UUID]*entities.LetterIncomingRecipient), + } + + errChan := make(chan error, 4) + + go func() { + var err error + result.attachments, err = s.processor.GetBatchAttachments(ctx, letterIDs) + errChan <- err + }() + + go func() { + var err error + result.priorities, err = s.processor.GetBatchPriorities(ctx, priorityIDs) + errChan <- err + }() + + go func() { + var err error + result.institutions, err = s.processor.GetBatchInstitutions(ctx, institutionIDs) + errChan <- err + }() + + go func() { + var err error + result.recipients, err = s.processor.GetBatchRecipientsByUser(ctx, letterIDs, userID) + errChan <- err + }() + + for i := 0; i < 4; i++ { + if err := <-errChan; err != nil { + logger.FromContext(ctx).Error("batch load error", err) + } + } + + resultChan <- result + }() + + batchData := <-resultChan + + respList := make([]contract.IncomingLetterResponse, 0, len(letters)) + for _, letter := range letters { + attachments := batchData.attachments[letter.ID] + if attachments == nil { + attachments = []entities.LetterIncomingAttachment{} + } + + var priority *entities.Priority + if letter.PriorityID != nil { + priority = batchData.priorities[*letter.PriorityID] + } + + var institution *entities.Institution + if letter.SenderInstitutionID != nil { + institution = batchData.institutions[*letter.SenderInstitutionID] + } + + isRead := false + if recipient, exists := batchData.recipients[letter.ID]; exists && recipient != nil { + isRead = recipient.ReadAt != nil + } + + resp := transformer.LetterEntityToContract(&letter, attachments, priority, institution) + resp.IsRead = isRead + respList = append(respList, *resp) + } + + totalUnread, _ := s.processor.CountUnreadByUser(ctx, userID) + + return &contract.ListIncomingLettersResponse{ + Letters: respList, + Pagination: transformer.CreatePaginationResponse(int(total), req.Page, req.Limit), + TotalUnread: totalUnread, + }, nil +} + +func (s *LetterServiceImpl) GetLetterUnreadCounts(ctx context.Context) (*contract.LetterUnreadCountResponse, error) { + return s.processor.GetLetterUnreadCounts(ctx) +} + +func (s *LetterServiceImpl) MarkIncomingLetterAsRead(ctx context.Context, letterID uuid.UUID) (*contract.MarkLetterReadResponse, error) { + return s.processor.MarkIncomingLetterAsRead(ctx, letterID) +} +func (s *LetterServiceImpl) MarkOutgoingLetterAsRead(ctx context.Context, letterID uuid.UUID) (*contract.MarkLetterReadResponse, error) { + return s.processor.MarkOutgoingLetterAsRead(ctx, letterID) } func (s *LetterServiceImpl) UpdateIncomingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) { return s.processor.UpdateIncomingLetter(ctx, id, req) @@ -47,7 +423,25 @@ func (s *LetterServiceImpl) SoftDeleteIncomingLetter(ctx context.Context, id uui } func (s *LetterServiceImpl) CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) { - return s.processor.CreateDispositions(ctx, req) + userID := appcontext.FromGinContext(ctx).UserID + req.CreatedBy = userID + + if req.FromDepartment == uuid.Nil { + req.FromDepartment = appcontext.FromGinContext(ctx).DepartmentID + } + + var result *contract.ListDispositionsResponse + err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { + var err error + result, err = s.processor.CreateDispositions(txCtx, req) + return err + }) + + if err != nil { + return nil, err + } + + return result, nil } func (s *LetterServiceImpl) GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) { @@ -61,3 +455,36 @@ func (s *LetterServiceImpl) CreateDiscussion(ctx context.Context, letterID uuid. func (s *LetterServiceImpl) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) { return s.processor.UpdateDiscussion(ctx, letterID, discussionID, req) } + +func (s *LetterServiceImpl) GetDepartmentDispositionStatus(ctx context.Context, req *contract.GetDepartmentDispositionStatusRequest) (*contract.ListDepartmentDispositionStatusResponse, error) { + return s.processor.GetDepartmentDispositionStatus(ctx, req) +} + +func (s *LetterServiceImpl) UpdateDispositionStatus(ctx context.Context, req *contract.UpdateDispositionStatusRequest) (*contract.DepartmentDispositionStatusResponse, error) { + // For now, delegate to the processor which handles this + // The processor needs to be refactored to remove context extraction + return s.processor.UpdateDispositionStatus(ctx, req) +} + +func (s *LetterServiceImpl) GetLetterCTA(ctx context.Context, letterID uuid.UUID) (*contract.LetterCTAResponse, error) { + departmentID := appcontext.FromGinContext(ctx).DepartmentID + return s.processor.GetLetterCTA(ctx, letterID, departmentID) +} + +func (s *LetterServiceImpl) BulkArchiveIncomingLetters(ctx context.Context, letterIDs []uuid.UUID) (*contract.BulkArchiveLettersResponse, error) { + // Extract user context to archive only for the current user + appCtx := appcontext.FromGinContext(ctx) + userID := appCtx.UserID + + // Archive letters only for the current user + archivedCount, err := s.processor.BulkArchiveIncomingLettersForUser(ctx, letterIDs, userID) + if err != nil { + return nil, err + } + + return &contract.BulkArchiveLettersResponse{ + Success: true, + Message: "Letters archived successfully", + ArchivedCount: int(archivedCount), + }, nil +} diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go new file mode 100644 index 0000000..e40c059 --- /dev/null +++ b/internal/service/notification_service.go @@ -0,0 +1,385 @@ +package service + +import ( + "context" + "fmt" + "net/url" + + "eslogad-be/internal/config" + "eslogad-be/internal/contract" + + "github.com/google/uuid" + novu "github.com/novuhq/go-novu/lib" +) + +type NotificationService interface { + TriggerNotification(ctx context.Context, req *contract.TriggerNotificationRequest) (*contract.TriggerNotificationResponse, error) + BulkTriggerNotification(ctx context.Context, req *contract.BulkTriggerNotificationRequest) (*contract.BulkTriggerNotificationResponse, error) + GetSubscriber(ctx context.Context, userID uuid.UUID) (*contract.GetSubscriberResponse, error) + UpdateSubscriberChannel(ctx context.Context, req *contract.UpdateSubscriberChannelRequest) (*contract.UpdateSubscriberChannelResponse, error) +} + +type NotificationServiceImpl struct { + client *novu.APIClient + config *config.NovuConfig + userProcessor UserProcessorForNotification +} + +type UserProcessorForNotification interface { + GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) +} + +func NewNotificationService(cfg *config.NovuConfig, userProcessor UserProcessorForNotification) *NotificationServiceImpl { + var client *novu.APIClient + if cfg.APIKey != "" { + // Create Novu config with backend URL + novuConfig := &novu.Config{} + if cfg.BaseURL != "" { + backendURL, err := url.Parse(cfg.BaseURL) + if err == nil { + novuConfig.BackendURL = backendURL + } + } + client = novu.NewAPIClient(cfg.APIKey, novuConfig) + } + + return &NotificationServiceImpl{ + client: client, + config: cfg, + userProcessor: userProcessor, + } +} + +func (s *NotificationServiceImpl) TriggerNotification(ctx context.Context, req *contract.TriggerNotificationRequest) (*contract.TriggerNotificationResponse, error) { + if s.client == nil { + return &contract.TriggerNotificationResponse{ + Success: false, + Message: "notification service not configured", + }, nil + } + + subscriberID := req.UserID.String() + + _, err := s.ensureSubscriberExists(ctx, req.UserID) + if err != nil { + return &contract.TriggerNotificationResponse{ + Success: false, + Message: fmt.Sprintf("failed to ensure subscriber exists: %v", err), + }, nil + } + + // Prepare the trigger payload + to := map[string]interface{}{ + "subscriberId": subscriberID, + } + + // Add additional recipient information if provided + if req.To != nil { + if req.To.Email != "" { + to["email"] = req.To.Email + } + if req.To.Phone != "" { + to["phone"] = req.To.Phone + } + } + + // Prepare overrides + overrides := make(map[string]interface{}) + if req.Overrides != nil { + if req.Overrides.Email != nil { + overrides["email"] = req.Overrides.Email + } + if req.Overrides.SMS != nil { + overrides["sms"] = req.Overrides.SMS + } + if req.Overrides.InApp != nil { + overrides["in_app"] = req.Overrides.InApp + } + if req.Overrides.Push != nil { + overrides["push"] = req.Overrides.Push + } + if req.Overrides.Chat != nil { + overrides["chat"] = req.Overrides.Chat + } + } + + // Trigger the notification using the template ID + triggerPayload := novu.ITriggerPayloadOptions{ + To: to, + Payload: req.TemplateData, + Overrides: overrides, + } + + resp, err := s.client.EventApi.Trigger(ctx, req.TemplateID, triggerPayload) + if err != nil { + return &contract.TriggerNotificationResponse{ + Success: false, + Message: fmt.Sprintf("failed to trigger notification: %v", err), + }, nil + } + + // Extract transaction ID from response + transactionID := "" + if respData, ok := resp.Data.(map[string]interface{}); ok { + if txID, exists := respData["transactionId"]; exists { + transactionID = fmt.Sprintf("%v", txID) + } + } + + return &contract.TriggerNotificationResponse{ + Success: true, + TransactionID: transactionID, + Message: "notification triggered successfully", + }, nil +} + +func (s *NotificationServiceImpl) BulkTriggerNotification(ctx context.Context, req *contract.BulkTriggerNotificationRequest) (*contract.BulkTriggerNotificationResponse, error) { + if s.client == nil { + return &contract.BulkTriggerNotificationResponse{ + Success: false, + TotalSent: 0, + TotalFailed: len(req.UserIDs), + }, nil + } + + results := make([]contract.NotificationResult, 0, len(req.UserIDs)) + successCount := 0 + failedCount := 0 + + for _, userID := range req.UserIDs { + // Create individual trigger request + triggerReq := &contract.TriggerNotificationRequest{ + UserID: userID, + TemplateID: req.TemplateID, + TemplateData: req.TemplateData, + Overrides: req.Overrides, + } + + resp, err := s.TriggerNotification(ctx, triggerReq) + + result := contract.NotificationResult{ + UserID: userID, + Success: resp.Success, + } + + if resp.Success { + result.TransactionID = resp.TransactionID + successCount++ + } else { + if err != nil { + result.Error = err.Error() + } else { + result.Error = resp.Message + } + failedCount++ + } + + results = append(results, result) + } + + return &contract.BulkTriggerNotificationResponse{ + Success: failedCount == 0, + TotalSent: successCount, + TotalFailed: failedCount, + Results: results, + }, nil +} + +func (s *NotificationServiceImpl) GetSubscriber(ctx context.Context, userID uuid.UUID) (*contract.GetSubscriberResponse, error) { + if s.client == nil { + return nil, fmt.Errorf("notification service not configured") + } + + subscriberID := userID.String() + + // Try to get the subscriber + subscriber, err := s.client.SubscriberApi.Get(ctx, subscriberID) + if err != nil { + // If subscriber doesn't exist, create it + _, createErr := s.ensureSubscriberExists(ctx, userID) + if createErr != nil { + return nil, fmt.Errorf("failed to get or create subscriber: %w", createErr) + } + + // Try to get again after creation + subscriber, err = s.client.SubscriberApi.Get(ctx, subscriberID) + if err != nil { + return nil, fmt.Errorf("failed to get subscriber after creation: %w", err) + } + } + + // Convert Novu subscriber to our response format + response := &contract.GetSubscriberResponse{ + SubscriberID: subscriberID, + } + + if subData, ok := subscriber.Data.(map[string]interface{}); ok { + if email, exists := subData["email"]; exists { + response.Email = fmt.Sprintf("%v", email) + } + if firstName, exists := subData["firstName"]; exists { + response.FirstName = fmt.Sprintf("%v", firstName) + } + if lastName, exists := subData["lastName"]; exists { + response.LastName = fmt.Sprintf("%v", lastName) + } + if phone, exists := subData["phone"]; exists { + response.Phone = fmt.Sprintf("%v", phone) + } + if avatar, exists := subData["avatar"]; exists { + response.Avatar = fmt.Sprintf("%v", avatar) + } + if data, exists := subData["data"]; exists { + if dataMap, ok := data.(map[string]interface{}); ok { + response.Data = dataMap + } + } + if channels, exists := subData["channels"]; exists { + if channelList, ok := channels.([]interface{}); ok { + response.Channels = make([]contract.ChannelCredentials, 0, len(channelList)) + for _, ch := range channelList { + if chMap, ok := ch.(map[string]interface{}); ok { + channelCred := contract.ChannelCredentials{} + if chType, exists := chMap["providerId"]; exists { + channelCred.Channel = fmt.Sprintf("%v", chType) + } + if creds, exists := chMap["credentials"]; exists { + if credMap, ok := creds.(map[string]interface{}); ok { + channelCred.Credentials = credMap + } + } + response.Channels = append(response.Channels, channelCred) + } + } + } + } + } + + return response, nil +} + +func (s *NotificationServiceImpl) UpdateSubscriberChannel(ctx context.Context, req *contract.UpdateSubscriberChannelRequest) (*contract.UpdateSubscriberChannelResponse, error) { + if s.client == nil { + return &contract.UpdateSubscriberChannelResponse{ + Success: false, + Message: "notification service not configured", + }, nil + } + + subscriberID := req.UserID.String() + + // Ensure subscriber exists + _, err := s.ensureSubscriberExists(ctx, req.UserID) + if err != nil { + return &contract.UpdateSubscriberChannelResponse{ + Success: false, + Message: fmt.Sprintf("failed to ensure subscriber exists: %v", err), + }, nil + } + + // Since the Novu Go SDK doesn't have UpdateCredentials, we'll update the subscriber data instead + // Get user info to update subscriber + user, err := s.userProcessor.GetUserByID(ctx, req.UserID) + if err != nil { + return &contract.UpdateSubscriberChannelResponse{ + Success: false, + Message: fmt.Sprintf("failed to get user data: %v", err), + }, nil + } + + // Prepare subscriber data with channel credentials stored in data + data := map[string]interface{}{ + "userId": user.ID.String(), + "email": user.Email, + "isActive": user.IsActive, + "updatedAt": user.UpdatedAt, + } + + // Store channel credentials in the subscriber data + channelKey := fmt.Sprintf("channel_%s", req.Channel) + data[channelKey] = req.Credentials + + // Update subscriber with new data + updateData := novu.SubscriberPayload{ + Email: user.Email, + FirstName: user.Name, + LastName: "", + Phone: "", + Avatar: "", + Data: data, + } + + _, err = s.client.SubscriberApi.Update(ctx, subscriberID, updateData) + if err != nil { + return &contract.UpdateSubscriberChannelResponse{ + Success: false, + Message: fmt.Sprintf("failed to update subscriber channel: %v", err), + }, nil + } + + return &contract.UpdateSubscriberChannelResponse{ + Success: true, + Message: "subscriber channel updated successfully", + }, nil +} + +func (s *NotificationServiceImpl) ensureSubscriberExists(ctx context.Context, userID uuid.UUID) (bool, error) { + subscriberID := userID.String() + + _, err := s.client.SubscriberApi.Get(ctx, subscriberID) + if err == nil { + return false, nil + } + + user, err := s.userProcessor.GetUserByID(ctx, userID) + if err != nil { + return false, fmt.Errorf("failed to get user data: %w", err) + } + + data := map[string]interface{}{ + "userId": user.ID.String(), + "email": user.Email, + "isActive": user.IsActive, + "createdAt": user.CreatedAt, + } + + if user.Roles != nil && len(user.Roles) > 0 { + roles := make([]map[string]interface{}, len(user.Roles)) + for i, role := range user.Roles { + roles[i] = map[string]interface{}{ + "id": role.ID.String(), + "name": role.Name, + "code": role.Code, + } + } + data["roles"] = roles + } + + if user.DepartmentResponse != nil && len(user.DepartmentResponse) > 0 { + depts := make([]map[string]interface{}, len(user.DepartmentResponse)) + for i, dept := range user.DepartmentResponse { + depts[i] = map[string]interface{}{ + "id": dept.ID.String(), + "name": dept.Name, + "code": dept.Code, + } + } + data["departments"] = depts + } + + subscriber := novu.SubscriberPayload{ + Email: user.Email, + FirstName: user.Name, + LastName: "", + Phone: "", + Avatar: "", + Data: data, + } + + _, err = s.client.SubscriberApi.Identify(ctx, subscriberID, subscriber) + if err != nil { + return false, fmt.Errorf("failed to create subscriber: %w", err) + } + + return true, nil +} diff --git a/internal/service/user_processor.go b/internal/service/user_processor.go index 8d4a69f..0400139 100644 --- a/internal/service/user_processor.go +++ b/internal/service/user_processor.go @@ -13,6 +13,7 @@ type UserProcessor interface { CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error) DeleteUser(ctx context.Context, id uuid.UUID) error GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) + GetUserByIDLight(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error) GetUserEntityByEmail(ctx context.Context, email string) (*entities.User, error) ChangePassword(ctx context.Context, userID uuid.UUID, req *contract.ChangePasswordRequest) error diff --git a/internal/transformer/letter_transformer.go b/internal/transformer/letter_transformer.go index b85f7df..359ff29 100644 --- a/internal/transformer/letter_transformer.go +++ b/internal/transformer/letter_transformer.go @@ -65,6 +65,38 @@ func LetterEntityToContract(e *entities.LetterIncoming, attachments []entities.L return resp } +func LetterIncomingEntityToContract(e *entities.LetterIncoming) *contract.IncomingLetterResponse { + if e == nil { + return nil + } + return &contract.IncomingLetterResponse{ + ID: e.ID, + LetterNumber: e.LetterNumber, + ReferenceNumber: e.ReferenceNumber, + Subject: e.Subject, + Description: e.Description, + ReceivedDate: e.ReceivedDate, + DueDate: e.DueDate, + Status: string(e.Status), + CreatedBy: e.CreatedBy, // Will be set conditionally + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Attachments: make([]contract.IncomingLetterAttachmentResponse, 0), + } +} + +func DepartmentEntityToContract(e *entities.Department) *contract.DepartmentResponse { + if e == nil { + return nil + } + return &contract.DepartmentResponse{ + ID: e.ID, + Code: e.Code, + Name: e.Name, + Path: e.Path, + } +} + func DispositionsToContract(list []entities.LetterIncomingDisposition) []contract.DispositionResponse { out := make([]contract.DispositionResponse, 0, len(list)) for _, d := range list { diff --git a/main b/main new file mode 100755 index 0000000..b1a15b3 Binary files /dev/null and b/main differ diff --git a/migrations/000028_add_status_disposition_department.down.sql b/migrations/000028_add_status_disposition_department.down.sql new file mode 100644 index 0000000..50296b6 --- /dev/null +++ b/migrations/000028_add_status_disposition_department.down.sql @@ -0,0 +1,25 @@ +-- Rollback: Remove status and letter_incoming_id columns from letter_incoming_dispositions_department table + +-- Drop the constraint first +ALTER TABLE letter_incoming_dispositions_department +DROP CONSTRAINT IF EXISTS check_disposition_department_status; + +-- Drop indexes +DROP INDEX IF EXISTS idx_letter_incoming_dispositions_department_status; +DROP INDEX IF EXISTS idx_letter_incoming_dispositions_department_letter_id; + +-- Drop the columns +ALTER TABLE letter_incoming_dispositions_department +DROP COLUMN IF EXISTS status; + +ALTER TABLE letter_incoming_dispositions_department +DROP COLUMN IF EXISTS letter_incoming_id; + +ALTER TABLE letter_incoming_dispositions_department +DROP COLUMN IF EXISTS read_at; + +ALTER TABLE letter_incoming_dispositions_department +DROP COLUMN IF EXISTS completed_at; + +ALTER TABLE letter_incoming_dispositions_department +DROP COLUMN IF EXISTS updated_at; \ No newline at end of file diff --git a/migrations/000028_add_status_disposition_department.up.sql b/migrations/000028_add_status_disposition_department.up.sql new file mode 100644 index 0000000..01e806e --- /dev/null +++ b/migrations/000028_add_status_disposition_department.up.sql @@ -0,0 +1,38 @@ +-- Add status and letter_incoming_id columns to letter_incoming_dispositions_department table + +-- Add letter_incoming_id column +ALTER TABLE letter_incoming_dispositions_department + ADD COLUMN IF NOT EXISTS letter_incoming_id UUID NOT NULL REFERENCES letters_incoming(id); + +-- Add status column with default value +ALTER TABLE letter_incoming_dispositions_department + ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'pending'; + +-- Add read_at column +ALTER TABLE letter_incoming_dispositions_department + ADD COLUMN IF NOT EXISTS read_at TIMESTAMP; + +-- Add completed_at column +ALTER TABLE letter_incoming_dispositions_department + ADD COLUMN IF NOT EXISTS completed_at TIMESTAMP; + +-- Add updated_at column +ALTER TABLE letter_incoming_dispositions_department + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + +-- Create index for faster queries +CREATE INDEX IF NOT EXISTS idx_letter_incoming_dispositions_department_status + ON letter_incoming_dispositions_department(department_id, status); + +CREATE INDEX IF NOT EXISTS idx_letter_incoming_dispositions_department_letter_id + ON letter_incoming_dispositions_department(letter_incoming_id); + +-- Update existing records to have status 'dispositioned' (assuming existing records are already dispositioned) +UPDATE letter_incoming_dispositions_department +SET status = 'dispositioned' +WHERE status = 'pending'; + +-- Add constraint to ensure valid status values +ALTER TABLE letter_incoming_dispositions_department + ADD CONSTRAINT check_disposition_department_status + CHECK (status IN ('pending', 'dispositioned', 'read', 'completed')); \ No newline at end of file diff --git a/migrations/000029_update_incoming_letter_department_recipients.down.sql b/migrations/000029_update_incoming_letter_department_recipients.down.sql new file mode 100644 index 0000000..2f40cb4 --- /dev/null +++ b/migrations/000029_update_incoming_letter_department_recipients.down.sql @@ -0,0 +1,12 @@ +BEGIN; + +-- Delete new setting +DELETE FROM app_settings WHERE key = 'INCOMING_LETTER_DEPARTMENT_RECIPIENTS'; + +-- Restore old setting +INSERT INTO app_settings(key, value) +VALUES + ('INCOMING_LETTER_RECIPIENTS', '{"department_codes": ["aslog"]}'::jsonb) +ON CONFLICT (key) DO NOTHING; + +COMMIT; \ No newline at end of file diff --git a/migrations/000029_update_incoming_letter_department_recipients.up.sql b/migrations/000029_update_incoming_letter_department_recipients.up.sql new file mode 100644 index 0000000..fc0ee3d --- /dev/null +++ b/migrations/000029_update_incoming_letter_department_recipients.up.sql @@ -0,0 +1,18 @@ +BEGIN; + +-- Delete old setting if exists +DELETE FROM app_settings WHERE key = 'INCOMING_LETTER_RECIPIENTS'; + +-- Insert new setting with department IDs as a direct array +-- Using empty array as placeholder that should be replaced with actual department IDs +INSERT INTO app_settings(key, value) +VALUES + ('INCOMING_LETTER_DEPARTMENT_RECIPIENTS', '[]'::jsonb) +ON CONFLICT (key) DO NOTHING; + +-- Example to set actual department IDs (uncomment and modify as needed): +-- UPDATE app_settings +-- SET value = '["ae66af10-bcb7-4939-8110-940d2a387e19"]'::jsonb +-- WHERE key = 'INCOMING_LETTER_DEPARTMENT_RECIPIENTS'; + +COMMIT; \ No newline at end of file diff --git a/migrations/000030_add_performance_indexes.down.sql b/migrations/000030_add_performance_indexes.down.sql new file mode 100644 index 0000000..ab6c624 --- /dev/null +++ b/migrations/000030_add_performance_indexes.down.sql @@ -0,0 +1,16 @@ +BEGIN; + +-- Drop indexes +DROP INDEX IF EXISTS idx_users_email; +DROP INDEX IF EXISTS idx_user_department_user_id; +DROP INDEX IF EXISTS idx_user_department_department_id; +DROP INDEX IF EXISTS idx_letter_outgoing_recipients_user_id; +DROP INDEX IF EXISTS idx_letter_outgoing_recipients_letter_id; +DROP INDEX IF EXISTS idx_letters_outgoing_status; +DROP INDEX IF EXISTS idx_letters_outgoing_deleted_at; +DROP INDEX IF EXISTS idx_letters_incoming_status; +DROP INDEX IF EXISTS idx_letters_incoming_deleted_at; +DROP INDEX IF EXISTS idx_letter_incoming_recipients_dept_id; +DROP INDEX IF EXISTS idx_letter_incoming_recipients_user_id; + +COMMIT; \ No newline at end of file diff --git a/migrations/000030_add_performance_indexes.up.sql b/migrations/000030_add_performance_indexes.up.sql new file mode 100644 index 0000000..bf119d7 --- /dev/null +++ b/migrations/000030_add_performance_indexes.up.sql @@ -0,0 +1,26 @@ +BEGIN; + +-- Add index for users.email for faster email lookups +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + +-- Add indexes for user_department relationship (singular table name) +CREATE INDEX IF NOT EXISTS idx_user_department_user_id ON user_department(user_id); +CREATE INDEX IF NOT EXISTS idx_user_department_department_id ON user_department(department_id); + +-- Add indexes for letter_outgoing_recipients for faster joins +CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_user_id ON letter_outgoing_recipients(user_id); +CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_letter_id ON letter_outgoing_recipients(letter_id); + +-- Add index for letters_outgoing status queries +CREATE INDEX IF NOT EXISTS idx_letters_outgoing_status ON letters_outgoing(status) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_letters_outgoing_deleted_at ON letters_outgoing(deleted_at); + +-- Add index for letters_incoming status queries +CREATE INDEX IF NOT EXISTS idx_letters_incoming_status ON letters_incoming(status) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_letters_incoming_deleted_at ON letters_incoming(deleted_at); + +-- Add index for letter_incoming_recipients for department lookups +CREATE INDEX IF NOT EXISTS idx_letter_incoming_recipients_dept_id ON letter_incoming_recipients(recipient_department_id); +CREATE INDEX IF NOT EXISTS idx_letter_incoming_recipients_user_id ON letter_incoming_recipients(recipient_user_id); + +COMMIT; \ No newline at end of file diff --git a/migrations/000031_add_missing_indexes.down.sql b/migrations/000031_add_missing_indexes.down.sql new file mode 100644 index 0000000..420a245 --- /dev/null +++ b/migrations/000031_add_missing_indexes.down.sql @@ -0,0 +1,9 @@ +BEGIN; + +-- Drop indexes +DROP INDEX IF EXISTS idx_user_profiles_user_id; +DROP INDEX IF EXISTS idx_departments_id; +DROP INDEX IF EXISTS idx_user_department_composite; +DROP INDEX IF EXISTS idx_users_id_not_deleted; + +COMMIT; \ No newline at end of file diff --git a/migrations/000031_add_missing_indexes.up.sql b/migrations/000031_add_missing_indexes.up.sql new file mode 100644 index 0000000..64b8846 --- /dev/null +++ b/migrations/000031_add_missing_indexes.up.sql @@ -0,0 +1,11 @@ +BEGIN; + +CREATE INDEX IF NOT EXISTS idx_user_profiles_user_id ON user_profiles(user_id); + +CREATE INDEX IF NOT EXISTS idx_departments_id ON departments(id); + +CREATE INDEX IF NOT EXISTS idx_user_department_composite ON user_department(user_id, department_id); + +CREATE INDEX IF NOT EXISTS idx_users_id_not_deleted ON users(id); + +COMMIT; \ No newline at end of file diff --git a/migrations/000032_add_system_user_and_department.down.sql b/migrations/000032_add_system_user_and_department.down.sql new file mode 100644 index 0000000..d12c098 --- /dev/null +++ b/migrations/000032_add_system_user_and_department.down.sql @@ -0,0 +1,20 @@ +BEGIN; + +-- Remove system user from department (cascade should handle this, but being explicit) +DELETE FROM user_department +WHERE user_id = '11111111-2222-3333-4444-555555555555'::uuid + AND department_id = '11111111-2222-3333-4444-555555555555'::uuid; + +-- Remove system user profile +DELETE FROM user_profiles +WHERE user_id = '11111111-2222-3333-4444-555555555555'::uuid; + +-- Remove system user +DELETE FROM users +WHERE id = '11111111-2222-3333-4444-555555555555'::uuid; + +-- Remove system department +DELETE FROM departments +WHERE id = '11111111-2222-3333-4444-555555555555'::uuid; + +COMMIT; \ No newline at end of file diff --git a/migrations/000032_add_system_user_and_department.up.sql b/migrations/000032_add_system_user_and_department.up.sql new file mode 100644 index 0000000..5494a42 --- /dev/null +++ b/migrations/000032_add_system_user_and_department.up.sql @@ -0,0 +1,88 @@ +BEGIN; + +INSERT INTO departments (id, name, code, path, created_at, updated_at) +VALUES ( + '11111111-2222-3333-4444-555555555555'::uuid, + 'System', + 'SYSTEM', + 'system'::ltree, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +) ON CONFLICT (id) DO NOTHING; + +INSERT INTO users ( + id, + username, + email, + name, + password_hash, + status, + is_active, + created_at, + updated_at +) +VALUES ( + '11111111-2222-3333-4444-555555555555'::uuid, + 'system', + 'system@eslogad.internal', + 'System User', + '$2a$10$SYSTEM.USER.SHOULD.NEVER.LOGIN.WITH.PASSWORD.HASH', + 'active', + true, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +) ON CONFLICT (id) DO NOTHING; + +-- Create user profile for system user +INSERT INTO user_profiles ( + user_id, + full_name, + display_name, + job_title, + preferences, + notification_prefs, + created_at, + updated_at +) +VALUES ( + '11111111-2222-3333-4444-555555555555'::uuid, + 'System User', + 'System', + 'Automated Process', + '{}'::jsonb, + '{}'::jsonb, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +) ON CONFLICT (user_id) DO NOTHING; + +-- Link system user to system department +INSERT INTO user_department ( + id, + user_id, + department_id, + is_primary, + assigned_at, + removed_at +) +VALUES ( + gen_random_uuid(), + '11111111-2222-3333-4444-555555555555'::uuid, + '11111111-2222-3333-4444-555555555555'::uuid, + true, + CURRENT_TIMESTAMP, + NULL +) ON CONFLICT (user_id, department_id) WHERE removed_at IS NULL DO NOTHING; + +-- Optionally, create a system role if your application uses roles +-- Uncomment and modify if needed: +-- INSERT INTO roles (id, code, name, description, created_at, updated_at) +-- VALUES ( +-- gen_random_uuid(), +-- 'SYSTEM', +-- 'System Role', +-- 'Role for system automated processes', +-- CURRENT_TIMESTAMP, +-- CURRENT_TIMESTAMP +-- ) ON CONFLICT (code) DO NOTHING; + +COMMIT; \ No newline at end of file diff --git a/migrations/000033_add_notes_to_disposition_department.down.sql b/migrations/000033_add_notes_to_disposition_department.down.sql new file mode 100644 index 0000000..70cf053 --- /dev/null +++ b/migrations/000033_add_notes_to_disposition_department.down.sql @@ -0,0 +1,10 @@ +BEGIN; + +-- Drop the index first +DROP INDEX IF EXISTS idx_letter_incoming_disposition_dept_status_notes; + +-- Remove notes column from letter_incoming_disposition_department table +ALTER TABLE letter_incoming_disposition_department +DROP COLUMN IF EXISTS notes; + +COMMIT; \ No newline at end of file diff --git a/migrations/000033_add_notes_to_disposition_department.up.sql b/migrations/000033_add_notes_to_disposition_department.up.sql new file mode 100644 index 0000000..5e0fcfa --- /dev/null +++ b/migrations/000033_add_notes_to_disposition_department.up.sql @@ -0,0 +1,12 @@ +BEGIN; + +-- Add notes column to letter_incoming_dispositions_department table +ALTER TABLE letter_incoming_dispositions_department +ADD COLUMN IF NOT EXISTS notes TEXT; + +-- Add index for better performance when querying by status with notes +CREATE INDEX IF NOT EXISTS idx_letter_incoming_disposition_dept_status_notes +ON letter_incoming_dispositions_department(status) +WHERE notes IS NOT NULL; + +COMMIT; \ No newline at end of file diff --git a/migrations/000034_add_archived_indexes.down.sql b/migrations/000034_add_archived_indexes.down.sql new file mode 100644 index 0000000..8b66549 --- /dev/null +++ b/migrations/000034_add_archived_indexes.down.sql @@ -0,0 +1,17 @@ +-- Drop indexes for letter_incoming_recipients +DROP INDEX IF EXISTS idx_letter_incoming_recipients_archived; +DROP INDEX IF EXISTS idx_letter_incoming_recipients_user_archived; +DROP INDEX IF EXISTS idx_letter_incoming_recipients_letter_archived; +DROP INDEX IF EXISTS idx_letter_incoming_recipients_dept_archived; + +-- Drop indexes for letter_outgoing_recipients +DROP INDEX IF EXISTS idx_letter_outgoing_recipients_archived; +DROP INDEX IF EXISTS idx_letter_outgoing_recipients_user_archived; +DROP INDEX IF EXISTS idx_letter_outgoing_recipients_letter_archived; + +-- Drop the is_archived columns +ALTER TABLE letter_incoming_recipients +DROP COLUMN IF EXISTS is_archived; + +ALTER TABLE letter_outgoing_recipients +DROP COLUMN IF EXISTS is_archived; \ No newline at end of file diff --git a/migrations/000034_add_archived_indexes.up.sql b/migrations/000034_add_archived_indexes.up.sql new file mode 100644 index 0000000..5aa2f21 --- /dev/null +++ b/migrations/000034_add_archived_indexes.up.sql @@ -0,0 +1,36 @@ +-- Add is_archived column to letter_incoming_recipients if it doesn't exist +ALTER TABLE letter_incoming_recipients +ADD COLUMN IF NOT EXISTS is_archived BOOLEAN DEFAULT FALSE; + +-- Add is_archived column to letter_outgoing_recipients if it doesn't exist +ALTER TABLE letter_outgoing_recipients +ADD COLUMN IF NOT EXISTS is_archived BOOLEAN DEFAULT FALSE; + +-- Add indexes for efficient archiving queries on letter_incoming_recipients +-- Note: letter_incoming_recipients uses recipient_user_id and recipient_department_id +CREATE INDEX IF NOT EXISTS idx_letter_incoming_recipients_archived +ON letter_incoming_recipients(is_archived, recipient_user_id, letter_id) +WHERE is_archived = false; + +CREATE INDEX IF NOT EXISTS idx_letter_incoming_recipients_user_archived +ON letter_incoming_recipients(recipient_user_id, is_archived, letter_id); + +CREATE INDEX IF NOT EXISTS idx_letter_incoming_recipients_letter_archived +ON letter_incoming_recipients(letter_id, is_archived); + +-- Add indexes for efficient archiving queries on letter_outgoing_recipients +-- Note: letter_outgoing_recipients uses user_id and department_id +CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_archived +ON letter_outgoing_recipients(is_archived, user_id, letter_id) +WHERE is_archived = false; + +CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_user_archived +ON letter_outgoing_recipients(user_id, is_archived, letter_id); + +CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_letter_archived +ON letter_outgoing_recipients(letter_id, is_archived); + +-- Add composite index for filtering non-archived letters by department +CREATE INDEX IF NOT EXISTS idx_letter_incoming_recipients_dept_archived +ON letter_incoming_recipients(recipient_department_id, is_archived, letter_id) +WHERE is_archived = false; \ No newline at end of file