diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..46fdb0c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "*", + "Bash(*)", + "Bash(cd:*)", + "Bash(mkdir:*)", + "Bash(cat:*)", + "Bash(go mod:*)", + "Bash(go build:*)" + ] + } +} diff --git a/.gitignore b/.gitignore index 497deaa..5639814 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ config/env/* !.env vendor +infra/*.yaml +!infra/*.yaml.example diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index f293eb5..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,32 +0,0 @@ -stages: - - build - - staging - -build_image: - stage: build - image: docker:20.10.9 - services: - - docker:20.10.9-dind - before_script: - - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - script: - - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA . - - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA - only: - - main - -deploy_to_staging: - stage: staging - image: - name: bitnami/kubectl - entrypoint: [""] - script: - - echo "$KUBECONFIG_BASE64" | base64 -d > ./kubeconfig - - export KUBECONFIG=$(pwd)/kubeconfig - - sed -i "s//$CI_COMMIT_SHORT_SHA/" k8s/staging/deployment.yaml - - kubectl apply -f k8s/staging/namespace.yaml - - kubectl apply -f k8s/staging/deployment.yaml - - kubectl apply -f k8s/staging/service.yaml - - kubectl apply -f k8s/staging/ingress.yaml - only: - - main diff --git a/API 1_N.docx b/API 1_N.docx new file mode 100644 index 0000000..89cc50a Binary files /dev/null and b/API 1_N.docx differ diff --git a/DOCKER.md b/DOCKER.md deleted file mode 100644 index 7c20420..0000000 --- a/DOCKER.md +++ /dev/null @@ -1,320 +0,0 @@ -# Docker Setup for APSKEL POS Backend - -This document describes how to run the APSKEL POS Backend using Docker and Docker Compose. - -## Prerequisites - -- Docker (version 20.10 or later) -- Docker Compose (version 2.0 or later) -- Git (for cloning the repository) -- Go 1.21+ (for local development) - -## Quick Start - -### 1. Build and Run Production Environment - -```bash -# Build and start all services -./docker-build.sh run - -# Or manually: -docker-compose up -d -``` - -The application will be available at: -- **Backend API**: http://localhost:3300 -- **Database**: localhost:5432 -- **Redis**: localhost:6379 - -### 2. Development Environment - -```bash -# Start development environment with live reload -./docker-build.sh dev - -# Or manually: -docker-compose --profile dev up -d -``` - -Development environment provides: -- **Backend API (Dev)**: http://localhost:3001 (with live reload) -- **Backend API (Prod)**: http://localhost:3300 -- Auto-restart on code changes using Air - -### 3. Database Migrations - -```bash -# Run database migrations -./docker-build.sh migrate - -# Or manually: -docker-compose --profile migrate up migrate -``` - -## Build Script Usage - -The `docker-build.sh` script provides convenient commands: - -```bash -# Build Docker image -./docker-build.sh build - -# Build and run production environment -./docker-build.sh run - -# Start development environment -./docker-build.sh dev - -# Run database migrations -./docker-build.sh migrate - -# Stop all containers -./docker-build.sh stop - -# Clean up containers and images -./docker-build.sh clean - -# Show container logs -./docker-build.sh logs - -# Show help -./docker-build.sh help -``` - -## Services - -### Backend API -- **Port**: 3300 (production), 3001 (development) -- **Health Check**: http://localhost:3300/health -- **Environment**: Configurable via `infra/` directory -- **User**: Runs as non-root user for security - -### PostgreSQL Database -- **Port**: 5432 -- **Database**: apskel_pos -- **Username**: apskel -- **Password**: See docker-compose.yaml -- **Volumes**: Persistent data storage - -### Redis Cache -- **Port**: 6379 -- **Usage**: Caching and session storage -- **Volumes**: Persistent data storage - -## Environment Configuration - -The application uses configuration files from the `infra/` directory: - -- `infra/development.yaml` - Development configuration -- `infra/production.yaml` - Production configuration (create if needed) - -### Configuration Structure - -```yaml -server: - port: 3300 - -postgresql: - host: postgres # Use service name in Docker - port: 5432 - db: apskel_pos - username: apskel - password: 7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk - -jwt: - token: - secret: "your-jwt-secret" - expires-ttl: 1440 - -s3: - access_key_id: "your-s3-key" - access_key_secret: "your-s3-secret" - endpoint: "your-s3-endpoint" - bucket_name: "your-bucket" - -log: - log_level: "info" - log_format: "json" -``` - -## Docker Compose Profiles - -### Default Profile (Production) -```bash -docker-compose up -d -``` -Starts: postgres, redis, backend - -### Development Profile -```bash -docker-compose --profile dev up -d -``` -Starts: postgres, redis, backend, backend-dev - -### Migration Profile -```bash -docker-compose --profile migrate up migrate -``` -Runs: database migrations - -## Health Checks - -All services include health checks: - -- **Backend**: HTTP GET /health -- **PostgreSQL**: pg_isready command -- **Redis**: Redis ping command - -## Logging - -View logs for specific services: - -```bash -# All services -docker-compose logs -f - -# Backend only -docker-compose logs -f backend - -# Database only -docker-compose logs -f postgres - -# Development backend -docker-compose logs -f backend-dev -``` - -## Volumes - -### Persistent Volumes -- `postgres_data`: Database files -- `redis_data`: Redis persistence files -- `go_modules`: Go module cache (development) - -### Bind Mounts -- `./infra:/infra:ro`: Configuration files (read-only) -- `./migrations:/app/migrations:ro`: Database migrations (read-only) -- `.:/app`: Source code (development only) - -## Security - -### Production Security Features -- Non-root user execution -- Read-only configuration mounts -- Minimal base image (Debian slim) -- Health checks for monitoring -- Resource limits (configurable) - -### Network Security -- Internal Docker network isolation -- Only necessary ports exposed -- Service-to-service communication via Docker network - -## Troubleshooting - -### Common Issues - -1. **Go Version Compatibility Error** - ```bash - # Error: package slices is not in GOROOT - # Solution: Make sure Dockerfile uses Go 1.21+ - # Check go.mod file requires Go 1.21 or later - ``` - -2. **Port Already in Use** - ```bash - # Check what's using the port - lsof -i :3300 - - # Change ports in docker-compose.yaml if needed - ``` - -3. **Database Connection Failed** - ```bash - # Check if database is running - docker-compose ps postgres - - # Check database logs - docker-compose logs postgres - ``` - -4. **Permission Denied** - ```bash - # Make sure script is executable - chmod +x docker-build.sh - ``` - -5. **Out of Disk Space** - ```bash - # Clean up unused Docker resources - docker system prune -a - - # Remove old images - docker image prune -a - ``` - -### Debug Mode - -Run containers in debug mode: - -```bash -# Start with debug logs -ENV_MODE=development docker-compose up - -# Enter running container -docker-compose exec backend sh - -# Check application logs -docker-compose logs -f backend -``` - -### Performance Tuning - -For production deployment: - -1. **Resource Limits**: Add resource limits to docker-compose.yaml -2. **Environment**: Use production configuration -3. **Logging**: Adjust log levels -4. **Health Checks**: Tune intervals for your needs - -```yaml -services: - backend: - deploy: - resources: - limits: - cpus: '1.0' - memory: 512M - reservations: - cpus: '0.5' - memory: 256M -``` - -## API Testing - -Once the application is running, test the API: - -```bash -# Health check -curl http://localhost:3300/health - -# Analytics endpoint (requires authentication) -curl -H "Authorization: Bearer " \ - -H "Organization-ID: " \ - "http://localhost:3300/api/v1/analytics/profit-loss?date_from=01-12-2023&date_to=31-12-2023" -``` - -## Deployment - -For production deployment: - -1. Update configuration in `infra/production.yaml` -2. Set appropriate environment variables -3. Use production Docker Compose file -4. Configure reverse proxy (nginx, traefik) -5. Set up SSL certificates -6. Configure monitoring and logging - -```bash -# Production deployment -ENV_MODE=production docker-compose -f docker-compose.prod.yaml up -d -``` \ No newline at end of file diff --git a/MIGRATION_SUMMARY.md b/MIGRATION_SUMMARY.md deleted file mode 100644 index a612e8f..0000000 --- a/MIGRATION_SUMMARY.md +++ /dev/null @@ -1,130 +0,0 @@ -# Table Restructuring Summary - -## Overview -This document summarizes the changes made to restructure the letter dispositions system from a single table to a more normalized structure with an association table. - -## Changes Made - -### 1. Database Schema Changes - -#### New Migration Files Created: -- `migrations/000012_rename_dispositions_table.up.sql` - Main migration to restructure tables -- `migrations/000012_rename_dispositions_table.down.sql` - Rollback migration - -#### Table Changes: -- **`letter_dispositions`** → **`letter_incoming_dispositions`** - - Renamed table - - Removed columns: `from_user_id`, `to_user_id`, `to_department_id`, `status`, `completed_at` - - Renamed `from_department_id` → `department_id` - - Added `read_at` column - - Kept columns: `id`, `letter_id`, `department_id`, `notes`, `read_at`, `created_at`, `created_by`, `updated_at` - -#### New Table Created: -- **`letter_incoming_dispositions_department`** - - Purpose: Associates dispositions with target departments - - Columns: `id`, `letter_incoming_disposition_id`, `department_id`, `created_at` - - Unique constraint on `(letter_incoming_disposition_id, department_id)` - -### 2. Entity Changes - -#### Updated Entities: -- **`LetterDisposition`** → **`LetterIncomingDisposition`** - - Simplified structure with only required fields - - New table name mapping - -#### New Entity: -- **`LetterIncomingDispositionDepartment`** - - Represents the many-to-many relationship between dispositions and departments - -### 3. Repository Changes - -#### Updated Repositories: -- **`LetterDispositionRepository`** → **`LetterIncomingDispositionRepository`** - - Updated to work with new entity - -#### New Repository: -- **`LetterIncomingDispositionDepartmentRepository`** - - Handles CRUD operations for the association table - - Methods: `CreateBulk`, `ListByDisposition` - -### 4. Processor Changes - -#### Updated Processor: -- **`LetterProcessorImpl`** - - Added new repository dependency - - Updated `CreateDispositions` method to: - - Create main disposition record - - Create department association records - - Maintain existing action selection functionality - -### 5. Transformer Changes - -#### Updated Transformer: -- **`DispositionsToContract`** function - - Updated to work with new entity structure - - Maps new fields: `DepartmentID`, `ReadAt`, `UpdatedAt` - - Removed old fields: `FromDepartmentID`, `ToDepartmentID`, `Status` - -### 6. Contract Changes - -#### Updated Contract: -- **`DispositionResponse`** struct - - Updated fields to match new entity structure - - Added `ReadAt` and `UpdatedAt` fields - - Replaced `FromDepartmentID` and `ToDepartmentID` with `DepartmentID` - -### 7. Application Configuration Changes - -#### Updated App Configuration: -- **`internal/app/app.go`** - - Updated repository initialization - - Added new repository dependency - - Updated processor initialization with new repository - -## Migration Process - -### Up Migration (000012_rename_dispositions_table.up.sql): -1. Rename `letter_dispositions` to `letter_incoming_dispositions` -2. Drop unnecessary columns -3. Rename `from_department_id` to `department_id` -4. Add missing columns (`read_at`, `updated_at`) -5. Create new association table -6. Update triggers and indexes - -### Down Migration (000012_rename_dispositions_table.down.sql): -1. Drop association table -2. Restore removed columns -3. Rename `department_id` back to `from_department_id` -4. Restore old triggers and indexes -5. Rename table back to `letter_dispositions` - -## Benefits of New Structure - -1. **Normalization**: Separates disposition metadata from department associations -2. **Flexibility**: Allows multiple departments per disposition -3. **Cleaner Data Model**: Removes redundant fields and simplifies the main table -4. **Better Performance**: Smaller main table with focused indexes -5. **Easier Maintenance**: Clear separation of concerns - -## Breaking Changes - -- Table name change from `letter_dispositions` to `letter_incoming_dispositions` -- Entity structure changes (removed fields, renamed fields) -- Repository interface changes -- API response structure changes - -## Testing Recommendations - -1. Run migration on test database -2. Test disposition creation with new structure -3. Verify department associations are created correctly -4. Test existing functionality (action selections, notes) -5. Verify rollback migration works correctly - -## Rollback Plan - -If issues arise, the down migration will: -1. Restore the original table structure -2. Preserve all existing data -3. Remove the new association table -4. Restore original triggers and indexes diff --git a/Makefile b/Makefile index fc655ba..2a015f0 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,11 @@ -#PROJECT_NAME = "enaklo-pos-backend" -DB_USERNAME :=eslogad_user -DB_PASSWORD :=M9u%24e%23jT2%40qR4pX%21zL -DB_HOST :=103.191.71.2 -DB_PORT :=5432 -DB_NAME :=eslogad_db +# Go Backend Template Makefile + +# Database configuration (update these for your project) +DB_USERNAME := postgres +DB_PASSWORD := postgres +DB_HOST := localhost +DB_PORT := 5432 +DB_NAME := template_db DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable @@ -19,45 +21,31 @@ help: @echo "Usage: make [command]" @echo @echo "Commands:" - @echo " rename-project name={name} Rename project" - @echo - @echo " build-http Build http server" - @echo - @echo " migration-create name={name} Create migration" - @echo " migration-up Up migrations" - @echo " migration-down Down last migration" - @echo - @echo " docker-up Up docker services" - @echo " docker-down Down docker services" - @echo - @echo " fmt Format source code" + @echo " build Build the application" + @echo " run Run the application" @echo " test Run unit tests" + @echo " fmt Format source code" + @echo + @echo " migration-create name={name} Create a new migration" + @echo " migration-up Run all pending migrations" + @echo " migration-down Rollback last migration" + @echo + @echo " docker-up Start docker services" + @echo " docker-down Stop docker services" @echo # Build -.SILENT: rename-project -rename-project: - ifeq ($(name),) - @echo 'new project name not set' - else - ifeq ($(DETECTED_OS),Darwin) - @grep -RiIl '$(PROJECT_NAME)' | xargs sed -i '' 's/$(PROJECT_NAME)/$(name)/g' - endif +.SILENT: build +build: + @go build -o ./bin/server ./cmd/server/main.go + @echo "✓ Binary built: ./bin/server" - ifeq ($(DETECTED_OS),Linux) - @grep -RiIl '$(PROJECT_NAME)' | xargs sed -i 's/$(PROJECT_NAME)/$(name)/g' - endif +# Run - ifeq ($(DETECTED_OS),Windows) - @grep 'target is not implemented on Windows platform' - endif - endif - -.SILENT: build-http -build-http: - @go build -o ./bin/http-server ./cmd/http/main.go - @echo executable file \"http-server\" saved in ./bin/http-server +.SILENT: run +run: + @ENV_MODE=development go run cmd/server/main.go # Test @@ -65,51 +53,62 @@ build-http: test: @go test ./... -v -# Create migration +# Format + +.SILENT: fmt +fmt: + @go fmt ./... + @echo "✓ Code formatted" + +# Migrations .SILENT: migration-create migration-create: + @if [ -z "$(name)" ]; then \ + echo "Error: name parameter is required"; \ + echo "Usage: make migration-create name=your_migration_name"; \ + exit 1; \ + fi @migrate create -ext sql -dir ./migrations -seq $(name) - -# Up migration + @echo "✓ Migration created: $(name)" .SILENT: migration-up migration-up: @migrate -database $(DB_URL) -path ./migrations up - -# Down migration + @echo "✓ Migrations applied" .SILENT: migration-down migration-down: @migrate -database $(DB_URL) -path ./migrations down 1 - -.SILENT: seeder-create -seeder-create: - @migrate create -ext sql -dir ./seeders -seq $(name) - -.SILENT: seeder-up -seeder-up: - @migrate -database $(DB_URL) -path ./seeders up + @echo "✓ Last migration rolled back" # Docker .SILENT: docker-up docker-up: @docker-compose up -d + @echo "✓ Docker services started" .SILENT: docker-down docker-down: @docker-compose down + @echo "✓ Docker services stopped" -# Format +# Dependencies -.SILENT: fmt -fmt: - @go fmt ./... +.SILENT: deps +deps: + @go mod download + @go mod tidy + @echo "✓ Dependencies updated" -start: - go run main.go --env-path .env +# Clean + +.SILENT: clean +clean: + @rm -rf ./bin + @echo "✓ Build artifacts cleaned" # Default -.DEFAULT_GOAL := help \ No newline at end of file +.DEFAULT_GOAL := help diff --git a/NOTIFICATION_API.md b/NOTIFICATION_API.md deleted file mode 100644 index 12a11e5..0000000 --- a/NOTIFICATION_API.md +++ /dev/null @@ -1,278 +0,0 @@ -# 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/README.md b/README.md index a97aee7..8c6625b 100644 --- a/README.md +++ b/README.md @@ -1,310 +1,342 @@ -

- Go
Backend Template -

+# Go Backend Template -> Clean architecture based backend template in Go. +> A clean, production-ready Go backend template with authentication, user management, and best practices built-in. -## Makefile +## Features -Makefile requires installed dependecies: -* [go](https://go.dev/doc/install) -* [docker-compose](https://docs.docker.com/compose/reference) -* [migrate](https://github.com/golang-migrate/migrate) - - -```shell -$ make - -Usage: make [command] - -Commands: - rename-project name={name} Rename project - - build-http Build http server - - migration-create name={name} Create migration - migration-up Up migrations - migration-down Down last migration - - docker-up Up docker services - docker-down Down docker services - - fmt Format source code - test Run unit tests - -``` - -## HTTP Server - -```shell -$ ./bin/http-server --help - -Usage: http-server - -Flags: - -h, --help Show mycontext-sensitive help. - --env-path=STRING Path to env config file -``` - -**Configuration** is based on the environment variables. See [.env.template](.env). - -```shell -# Expose env vars before and start server -$ ./bin/http-server - -# Expose env vars from the file and start server -$ ./bin/http-server --env-path ./config/env/.env -``` - -## API Docs -* [eslogad Backend](https://eslogad-be.app-dev.altru.id/docs/index.html#/) - -## License - -This project is licensed under the [MIT License](https://github.com/pvarentsov/eslogad-be/blob/main/LICENSE). - -# Apskel POS Backend - -A SaaS Point of Sale (POS) Restaurant System backend built with clean architecture principles in Go. - -## Architecture Overview - -This application follows a clean architecture pattern with clear separation of concerns: - -``` -Handler → Service → Processor → Repository -``` - -### Layers - -1. **Contract Package** (`internal/contract/`) - - Request/Response DTOs for API communication - - Contains JSON tags for serialization - - Input validation tags - -2. **Handler Layer** (`internal/handler/`) - - HTTP request/response handling - - Request validation using go-playground/validator - - Route definitions and middleware - - Transforms contracts to/from services - -3. **Service Layer** (`internal/service/`) - - Business logic orchestration - - Calls processors and transformers - - Coordinates between different business operations - -4. **Processor Layer** (`internal/processor/`) - - Complex business operations - - Cross-repository transactions - - Business rule enforcement - - Handles operations like order creation with inventory updates - -5. **Repository Layer** (`internal/repository/`) - - Data access layer - - Individual repository per entity - - Database-specific operations - - Uses entities for database models - -6. **Supporting Packages**: - - **Models** (`internal/models/`) - Pure business logic models (no database dependencies) - - **Entities** (`internal/entities/`) - Database models with GORM tags - - **Constants** (`internal/constants/`) - Type-safe enums and business constants - - **Transformer** (`internal/transformer/`) - Contract ↔ Model conversions - - **Mappers** (`internal/mappers/`) - Model ↔ Entity conversions - -## Key Features - -- **Clean Architecture**: Strict separation between business logic and infrastructure -- **Type Safety**: Constants package with validation helpers -- **Validation**: Comprehensive request validation using go-playground/validator -- **Error Handling**: Structured error responses with proper HTTP status codes -- **Database Independence**: Business logic never depends on database implementation -- **Testability**: Each layer can be tested independently - -## API Endpoints - -### Health Check -- `GET /api/v1/health` - Health check endpoint - -### Organizations -- `POST /api/v1/organizations` - Create organization -- `GET /api/v1/organizations` - List organizations -- `GET /api/v1/organizations/{id}` - Get organization by ID -- `PUT /api/v1/organizations/{id}` - Update organization -- `DELETE /api/v1/organizations/{id}` - Delete organization - -### Users -- `POST /api/v1/users` - Create user -- `GET /api/v1/users` - List users -- `GET /api/v1/users/{id}` - Get user by ID -- `PUT /api/v1/users/{id}` - Update user -- `DELETE /api/v1/users/{id}` - Delete user -- `PUT /api/v1/users/{id}/password` - Change password -- `PUT /api/v1/users/{id}/activate` - Activate user -- `PUT /api/v1/users/{id}/deactivate` - Deactivate user - -### Orders -- `POST /api/v1/orders` - Create order with items -- `GET /api/v1/orders` - List orders -- `GET /api/v1/orders/{id}` - Get order by ID -- `GET /api/v1/orders/{id}?include_items=true` - Get order with items -- `PUT /api/v1/orders/{id}` - Update order -- `PUT /api/v1/orders/{id}/cancel` - Cancel order -- `PUT /api/v1/orders/{id}/complete` - Complete order -- `POST /api/v1/orders/{id}/items` - Add item to order - -### Order Items -- `PUT /api/v1/order-items/{id}` - Update order item -- `DELETE /api/v1/order-items/{id}` - Remove order item - -## Installation - -1. **Clone the repository** - ```bash - git clone - cd eslogad-backend - ``` - -2. **Install dependencies** - ```bash - go mod tidy - ``` - -3. **Set up database** - ```bash - # Set your PostgreSQL database URL - export DATABASE_URL="postgres://username:password@localhost:5432/apskel_pos?sslmode=disable" - ``` - -4. **Run migrations** - ```bash - make migration-up - ``` - -## Usage - -### Development - -```bash -# Start the server -go run cmd/server/main.go -port 8080 -db-url "postgres://username:password@localhost:5432/apskel_pos?sslmode=disable" - -# Or using environment variable -export DATABASE_URL="postgres://username:password@localhost:5432/apskel_pos?sslmode=disable" -go run cmd/server/main.go -port 8080 -``` - -### Using Make Commands - -```bash -# Run the application -make start - -# Format code -make fmt - -# Run tests -make test - -# Build for production -make build-http - -# Docker operations -make docker-up -make docker-down - -# Database migrations -make migration-create name=create_users_table -make migration-up -make migration-down -``` - -## Example API Usage - -### Create Organization -```bash -curl -X POST http://localhost:8080/api/v1/organizations \ - -H "Content-Type: application/json" \ - -d '{ - "name": "My Restaurant", - "plan_type": "premium" - }' -``` - -### Create User -```bash -curl -X POST http://localhost:8080/api/v1/users \ - -H "Content-Type: application/json" \ - -d '{ - "organization_id": "uuid-here", - "username": "john_doe", - "email": "john@example.com", - "password": "password123", - "full_name": "John Doe", - "role": "manager" - }' -``` - -### Create Order with Items -```bash -curl -X POST http://localhost:8080/api/v1/orders \ - -H "Content-Type: application/json" \ - -d '{ - "outlet_id": "uuid-here", - "user_id": "uuid-here", - "table_number": "A1", - "order_type": "dine_in", - "notes": "No onions", - "order_items": [ - { - "product_id": "uuid-here", - "quantity": 2, - "unit_price": 15.99 - } - ] - }' -``` +- **Clean Architecture** - Separation of concerns with handler → service → processor → repository layers +- **Authentication & Authorization** - JWT-based auth with role-based access control +- **Database Ready** - PostgreSQL integration with GORM +- **Middleware Stack** - CORS, logging, recovery, correlation ID tracking +- **Configuration Management** - Environment-based config with Viper +- **Structured Logging** - Zap and Logrus integration +- **Input Validation** - Request validation with go-playground/validator +- **Graceful Shutdown** - Proper cleanup on termination signals ## Project Structure ``` -eslogad-backend/ +. ├── cmd/ -│ └── server/ # Application entry point +│ └── server/ # Application entry point +├── config/ # Configuration management +│ ├── configs.go # Main config loader +│ ├── db.go # Database config +│ ├── jwt.go # JWT config +│ ├── log.go # Logging config +│ ├── s3.go # S3/file storage config +│ └── server.go # Server config ├── internal/ -│ ├── app/ # Application wiring and dependency injection -│ ├── contract/ # API contracts (request/response DTOs) -│ ├── handler/ # HTTP handlers and routes -│ ├── service/ # Business logic orchestration -│ ├── processor/ # Complex business operations -│ ├── repository/ # Data access layer -│ ├── models/ # Pure business models -│ ├── entities/ # Database entities (GORM models) -│ ├── constants/ # Business constants and enums -│ ├── transformer/ # Contract ↔ Model transformations -│ └── mappers/ # Model ↔ Entity transformations -├── migrations/ # Database migrations -├── Makefile # Build and development commands -├── go.mod # Go module definition -└── README.md # This file +│ ├── app/ # Application initialization +│ ├── appcontext/ # Request context utilities +│ ├── constant/ # Application constants +│ ├── constants/ # Business constants +│ ├── contract/ # API request/response DTOs +│ ├── db/ # Database connection +│ ├── entities/ # Database models (GORM) +│ ├── handler/ # HTTP handlers +│ ├── logger/ # Logging utilities +│ ├── middleware/ # HTTP middleware +│ ├── processor/ # Business logic processors +│ ├── repository/ # Data access layer +│ ├── router/ # Route definitions +│ ├── service/ # Service layer +│ ├── transformer/ # DTO transformers +│ ├── util/ # Utility functions +│ └── validator/ # Custom validators +├── migrations/ # Database migrations +├── infra/ # Infrastructure configs (YAML) +├── go.mod +├── go.sum +├── Makefile +└── README.md ``` +## Architecture Layers + +### 1. Handler Layer (`internal/handler/`) +- HTTP request/response handling +- Request validation +- Route definitions +- Transforms contracts to/from services + +### 2. Service Layer (`internal/service/`) +- Business logic orchestration +- Coordinates between processors +- Transaction management + +### 3. Processor Layer (`internal/processor/`) +- Complex business operations +- Cross-repository transactions +- Business rule enforcement + +### 4. Repository Layer (`internal/repository/`) +- Data access layer +- Database operations +- GORM integration + +### 5. Supporting Packages +- **Contract** - API DTOs with JSON tags +- **Entities** - Database models with GORM tags +- **Transformer** - Contract ↔ Entity conversions +- **Middleware** - Request processing pipeline +- **Validator** - Custom validation logic + +## Getting Started + +### Prerequisites + +- Go 1.21 or higher +- PostgreSQL 12+ +- Make (optional, for using Makefile commands) + +### Installation + +1. **Clone this template** + ```bash + git clone + cd go-backend-template + ``` + +2. **Rename the project** + + Update the module name in `go.mod`: + ```go + module your-project-name + ``` + + Then update all imports: + ```bash + find . -name "*.go" -type f -exec sed -i '' 's|go-backend-template|your-project-name|g' {} \; + ``` + +3. **Install dependencies** + ```bash + go mod download + ``` + +4. **Set up configuration** + + Create your environment config file in `infra/`: + ```bash + cp infra/development.yaml infra/development.yaml + ``` + + Edit `infra/development.yaml` with your settings: + ```yaml + server: + port: "8080" + + postgresql: + host: "localhost" + port: "5432" + user: "postgres" + password: "your-password" + database: "your-database" + sslmode: "disable" + + jwt: + token: + secret: "your-secret-key" + expires_ttl: 3600 + + log: + log_level: "info" + log_format: "json" + ``` + +5. **Run database migrations** + ```bash + make migration-up + ``` + +6. **Start the server** + ```bash + # Set environment mode + export ENV_MODE=development + + # Run the server + go run cmd/server/main.go + ``` + +## Configuration + +The application uses environment-based YAML configuration files located in the `infra/` directory: + +- `infra/local.yaml` - Local development +- `infra/development.yaml` - Development environment +- `infra/production.yaml` - Production environment + +Set the `ENV_MODE` environment variable to select the configuration: +```bash +export ENV_MODE=development # or local, production +``` + +## API Endpoints + +### Health Check +- `GET /health` - Health check endpoint + +### Authentication +- `POST /api/v1/auth/login` - User login +- `POST /api/v1/auth/refresh` - Refresh access token +- `GET /api/v1/auth/profile` - Get authenticated user profile + +### Users (Protected) +- `POST /api/v1/users` - Create user +- `GET /api/v1/users` - List users +- `PUT /api/v1/users/:id` - Update user +- `DELETE /api/v1/users/:id` - Delete user +- `GET /api/v1/users/profile` - Get current user profile +- `PUT /api/v1/users/profile` - Update current user profile +- `PUT /api/v1/users/:id/password` - Change user password + +## Development + +### Running with Air (Hot Reload) + +Install Air: +```bash +go install github.com/cosmtrek/air@latest +``` + +Run with hot reload: +```bash +air +``` + +### Database Migrations + +Create a new migration: +```bash +make migration-create name=create_your_table +``` + +Run migrations: +```bash +make migration-up +``` + +Rollback last migration: +```bash +make migration-down +``` + +### Code Formatting + +```bash +make fmt +``` + +### Running Tests + +```bash +make test +``` + +## Building for Production + +Build the binary: +```bash +make build-http +``` + +The binary will be created in `./bin/http-server`. + +Run the production binary: +```bash +./bin/http-server +``` + +## Docker Support + +Build Docker image: +```bash +docker build -t your-app-name . +``` + +Run with Docker Compose: +```bash +docker-compose up +``` + +## Customization Guide + +### Adding a New Feature + +1. **Define the entity** in `internal/entities/` +2. **Create migration** for the database table +3. **Add repository** in `internal/repository/` +4. **Create processor** in `internal/processor/` for business logic +5. **Add service** in `internal/service/` for orchestration +6. **Define contracts** in `internal/contract/` for API DTOs +7. **Create handler** in `internal/handler/` for HTTP endpoints +8. **Register routes** in `internal/router/router.go` +9. **Wire dependencies** in `internal/app/app.go` + +### Example: Adding a "Product" Feature + +```go +// 1. Entity (internal/entities/product.go) +type Product struct { + ID string `gorm:"primaryKey"` + Name string `gorm:"not null"` + Price float64 `gorm:"not null"` + CreatedAt time.Time +} + +// 2. Repository (internal/repository/product_repository.go) +type ProductRepository interface { + Create(ctx context.Context, product *entities.Product) error + FindByID(ctx context.Context, id string) (*entities.Product, error) +} + +// 3. Service (internal/service/product_service.go) +type ProductService interface { + CreateProduct(ctx context.Context, req *contract.CreateProductRequest) error +} + +// 4. Handler (internal/handler/product_handler.go) +func (h *ProductHandler) CreateProduct(c *gin.Context) { + // Handle HTTP request +} + +// 5. Register in router (internal/router/router.go) +products := v1.Group("/products") +products.Use(r.authMiddleware.RequireAuth()) +{ + products.POST("", r.productHandler.CreateProduct) + products.GET("/:id", r.productHandler.GetProduct) +} +``` + +## Environment Variables + +- `ENV_MODE` - Environment mode (local, development, production) + ## Dependencies -- **[Gorilla Mux](https://github.com/gorilla/mux)** - HTTP router and URL matcher -- **[GORM](https://gorm.io/)** - ORM for database operations -- **[PostgreSQL Driver](https://github.com/lib/pq)** - PostgreSQL database driver -- **[Validator](https://github.com/go-playground/validator)** - Struct validation -- **[UUID](https://github.com/google/uuid)** - UUID generation and parsing +Key dependencies: +- **Gin** - HTTP web framework +- **GORM** - ORM library +- **Viper** - Configuration management +- **Zap/Logrus** - Structured logging +- **JWT** - JSON Web Token authentication +- **Validator** - Struct validation +- **AWS SDK** - S3 file storage (optional) + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. ## Contributing 1. Fork the repository -2. Create a feature branch -3. Commit your changes -4. Push to the branch -5. Create a Pull Request +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request -## License +## Support -This project is licensed under the MIT License - see the LICENSE file for details. +For issues and questions, please open an issue on GitHub. diff --git a/cmd/server/main.go b/cmd/server/main.go index 82bac29..53a111c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,23 +1,20 @@ package main import ( - "eslogad-be/config" - "eslogad-be/internal/app" - "eslogad-be/internal/db" - "eslogad-be/internal/logger" + "go-backend-template/config" + "go-backend-template/internal/app" + "go-backend-template/internal/logger" "log" ) func main() { cfg := config.LoadConfig() + log.Printf("DEBUG: Config loaded successfully") + log.Printf("DEBUG: Port from config: %q", cfg.Port()) + logger.Setup(cfg.LogLevel(), cfg.LogFormat()) - db, err := db.NewPostgres(cfg.Database) - if err != nil { - log.Fatal(err) - } - - application := app.NewApp(db) + application := app.NewApp() if err := application.Initialize(cfg); err != nil { log.Fatalf("Failed to initialize application: %v", err) diff --git a/config/configs.go b/config/configs.go index db040c8..f6feb76 100644 --- a/config/configs.go +++ b/config/configs.go @@ -24,14 +24,12 @@ var ( ) type Config struct { - Server Server `mapstructure:"server"` - Database Database `mapstructure:"postgresql"` - Jwt Jwt `mapstructure:"jwt"` - Log Log `mapstructure:"log"` - S3Config S3Config `mapstructure:"s3"` - OnlyOffice OnlyOffice `mapstructure:"onlyoffice"` - Novu Novu `mapstructure:"novu"` - Department Department `mapstructure:"department"` + Server Server `mapstructure:"server"` + Database Database `mapstructure:"postgresql"` + Jwt Jwt `mapstructure:"jwt"` + Log Log `mapstructure:"log"` + S3Config S3Config `mapstructure:"s3"` + Dukcapil Dukcapil `mapstructure:"dukcapil"` } var ( @@ -83,19 +81,3 @@ func (c *Config) LogFormat() string { return c.Log.LogFormat } -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"` -} - -type Department struct { - ParentPath string `mapstructure:"parent_path"` - ExcludedPaths []string `mapstructure:"excluded_paths"` -} diff --git a/config/dukcapil.go b/config/dukcapil.go new file mode 100644 index 0000000..767434b --- /dev/null +++ b/config/dukcapil.go @@ -0,0 +1,22 @@ +package config + +import "time" + +// Dukcapil holds configuration for the Dukcapil Face Recognition (1:N) API. +type Dukcapil struct { + BaseURL string `mapstructure:"base_url"` + CustomerID string `mapstructure:"customer_id"` + Methode string `mapstructure:"methode"` + UserID string `mapstructure:"user_id"` + Password string `mapstructure:"password"` + PublicKeyPath string `mapstructure:"public_key_path"` + DefaultIP string `mapstructure:"default_ip"` + TimeoutSecond int `mapstructure:"timeout_second"` +} + +func (d *Dukcapil) Timeout() time.Duration { + if d.TimeoutSecond <= 0 { + return 30 * time.Second + } + return time.Duration(d.TimeoutSecond) * time.Second +} diff --git a/deployment.sh b/deployment.sh deleted file mode 100644 index 677eb8f..0000000 --- a/deployment.sh +++ /dev/null @@ -1,25 +0,0 @@ -APP_NAME="eslogad" -PORT="4000" -NETWORK_NAME="pgnet" - -echo "🔄 Pulling latest code..." -git pull - -echo "🐳 Building Docker image..." -docker build -t "$APP_NAME:latest" . - -echo "🛑 Stopping and removing old container..." -docker stop "$APP_NAME" 2>/dev/null || true -docker rm "$APP_NAME" 2>/dev/null || true - -echo "🔌 Ensuring Docker network exists..." -docker network inspect "$NETWORK_NAME" >/dev/null 2>&1 || docker network create "$NETWORK_NAME" - -echo "🚀 Running new container..." -docker run -d \ - --name "$APP_NAME" \ - --network "$NETWORK_NAME" \ - -p "$PORT:$PORT" \ - -v "$(pwd)/infra":/infra:ro \ - -v "$(pwd)/templates":/templates:ro \ - "$APP_NAME:latest" \ No newline at end of file diff --git a/docker-build.sh b/docker-build.sh deleted file mode 100755 index 0239cf5..0000000 --- a/docker-build.sh +++ /dev/null @@ -1,211 +0,0 @@ -#!/bin/bash - -# Docker build script for eslogad-backend - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Functions -log_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -log_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Help function -show_help() { - echo "Usage: $0 [OPTION]" - echo "Build and manage Docker containers for eslogad-backend" - echo "" - echo "Options:" - echo " build Build the Docker image" - echo " run Run the production container" - echo " dev Run development environment with live reload" - echo " migrate Run database migrations" - echo " stop Stop all containers" - echo " clean Remove containers and images" - echo " logs Show container logs" - echo " help Show this help message" - echo "" -} - -# Build Docker image -build_image() { - log_info "Building eslogad-backend Docker image..." - - # Check if Go build works locally first (optional quick test) - if command -v go &> /dev/null; then - log_info "Testing Go build locally first..." - if go build -o /tmp/test-build cmd/server/main.go 2>/dev/null; then - log_success "Local Go build test passed" - rm -f /tmp/test-build - else - log_warning "Local Go build test failed, but continuing with Docker build..." - fi - fi - - # Build the image with production target - docker build \ - --target production \ - -t eslogad-backend:latest \ - -t eslogad-backend:$(date +%Y%m%d-%H%M%S) \ - . - - if [ $? -eq 0 ]; then - log_success "Docker image built successfully!" - else - log_error "Failed to build Docker image" - log_info "Make sure you're using Go 1.21+ and all dependencies are available" - exit 1 - fi -} - -# Run production container -run_container() { - log_info "Starting production containers..." - - # Start the containers - docker-compose up -d - - if [ $? -eq 0 ]; then - log_success "Containers started successfully!" - log_info "Backend API available at: http://localhost:3300" - log_info "Database available at: localhost:5432" - log_info "Redis available at: localhost:6379" - log_info "" - log_info "Use 'docker-compose logs -f backend' to view logs" - else - log_error "Failed to start containers" - exit 1 - fi -} - -# Run development environment -run_dev() { - log_info "Starting development environment..." - - # Start development containers - docker-compose --profile dev up -d - - if [ $? -eq 0 ]; then - log_success "Development environment started!" - log_info "Backend API (dev) available at: http://localhost:3001" - log_info "Backend API (prod) available at: http://localhost:3300" - log_info "" - log_info "Use 'docker-compose logs -f backend-dev' to view development logs" - else - log_error "Failed to start development environment" - exit 1 - fi -} - -# Run migrations -run_migrations() { - log_info "Running database migrations..." - - # Ensure database is running - docker-compose up -d postgres - sleep 5 - - # Run migrations - docker-compose --profile migrate up migrate - - if [ $? -eq 0 ]; then - log_success "Migrations completed successfully!" - else - log_warning "Migrations may have failed or are already up to date" - fi -} - -# Stop containers -stop_containers() { - log_info "Stopping all containers..." - - docker-compose down - - if [ $? -eq 0 ]; then - log_success "All containers stopped" - else - log_error "Failed to stop containers" - exit 1 - fi -} - -# Clean up containers and images -clean_up() { - log_warning "This will remove all containers, networks, and images related to this project" - read -p "Are you sure? (y/N): " -n 1 -r - echo - - if [[ $REPLY =~ ^[Yy]$ ]]; then - log_info "Cleaning up containers and images..." - - # Stop and remove containers - docker-compose down -v --remove-orphans - - # Remove images - docker rmi eslogad-backend:latest || true - docker rmi $(docker images eslogad-backend -q) || true - - # Remove unused networks and volumes - docker network prune -f || true - docker volume prune -f || true - - log_success "Cleanup completed" - else - log_info "Cleanup cancelled" - fi -} - -# Show logs -show_logs() { - log_info "Showing container logs..." - - # Show logs for all services - docker-compose logs -f -} - -# Main script logic -case "${1:-help}" in - "build") - build_image - ;; - "run") - build_image - run_container - ;; - "dev") - run_dev - ;; - "migrate") - run_migrations - ;; - "stop") - stop_containers - ;; - "clean") - clean_up - ;; - "logs") - show_logs - ;; - "help"|*) - show_help - ;; -esac \ No newline at end of file diff --git a/go.mod b/go.mod index b5c7dd5..5de176a 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module eslogad-be +module go-backend-template go 1.21 @@ -38,7 +38,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/spf13/afero v1.9.5 // indirect github.com/spf13/cast v1.5.1 // indirect @@ -62,7 +61,6 @@ require ( require ( github.com/aws/aws-sdk-go v1.55.7 github.com/golang-jwt/jwt/v5 v5.2.3 - github.com/novuhq/go-novu v0.1.2 github.com/sirupsen/logrus v1.9.3 go.uber.org/zap v1.21.0 golang.org/x/crypto v0.28.0 diff --git a/go.sum b/go.sum index 8d13098..e91517f 100644 --- a/go.sum +++ b/go.sum @@ -209,8 +209,6 @@ 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 1fde617..7e133b0 100644 --- a/infra/development.yaml +++ b/infra/development.yaml @@ -1,50 +1,38 @@ server: - base-url: - local-url: - port: 4000 + port: "8080" + +postgresql: + host: "localhost" + port: "5432" + user: "postgres" + password: "postgres" + database: "eslogad_db" + sslmode: "disable" + max_open_conns: 25 + max_idle_conns: 5 + conn_max_lifetime: 300 jwt: token: - expires-ttl: 1440 - secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs" - -postgresql: - host: pg16 - port: 5432 - driver: postgres - db: eslogad_db - username: eslogad_user - password: 'M9u$e#jT2@qR4pX!zL' - ssl-mode: disable - max-idle-connections-in-second: 600 - max-open-connections-in-second: 600 - connection-max-life-time-in-second: 600 - debug: false - -s3: - access_key_id: minioadmin # from MINIO_ROOT_USER or Access Key you created in console - access_key_secret: minioadmin123 # from MINIO_ROOT_PASSWORD or Secret Key you created in console - endpoint: https://noken-log-s3.tni-ad.mil.id # S3 API endpoint, not console port - bucket_name: enaklo - log_level: Error - host_url: 'https://noken-log-s3.tni-ad.mil.id' + secret: "your-secret-key-change-this-in-production" + expires_ttl: 3600 log: - log_format: 'json' - log_level: 'debug' + log_level: "info" + log_format: "json" -onlyoffice: - url: 'https://noken-log-onlyoffice.tni-ad.mil.id' - token: 'Si7vqBAZElQzeQF2KUbN5j9qKc1GX0kq' +s3: + region: "us-east-1" + bucket: "your-bucket-name" + access_key_id: "your-access-key" + secret_access_key: "your-secret-key" -novu: - api_key: 'f7de60a16abf825996191bf69ea8054a' # Add your Novu API key here - application_id: 'cDeX8L5VWe-r' # Add your Novu Application ID here - base_url: 'https://noken-log-novu-api.tni-ad.mil.id' # Optional: defaults to https://api.novu.co - incoming_letter_workflow_id: 'notification-dashbpard' - -department: - parent_path: 'eslogad.aslog' # Parent path for departments to be included in API - excluded_paths: # Paths to exclude from department APIs - - 'superadmin' - - 'system' \ No newline at end of file +dukcapil: + base_url: "http://172.16.160.176:8080/api/face-recognition" + customer_id: "your-customer-id" + methode: "CALL_FN" + user_id: "281020241202039900305241000011252" + password: "Fjskdhv35$%" + public_key_path: "infra/dukcapil_public.pem" + default_ip: "10.160.86.53" + timeout_second: 30 diff --git a/infra/local.yaml.example b/infra/local.yaml.example new file mode 100644 index 0000000..e52c274 --- /dev/null +++ b/infra/local.yaml.example @@ -0,0 +1,38 @@ +server: + port: "8080" + +postgresql: + host: "localhost" + port: "5432" + user: "postgres" + password: "postgres" + database: "template_db" + sslmode: "disable" + max_open_conns: 25 + max_idle_conns: 5 + conn_max_lifetime: 300 + +jwt: + token: + secret: "local-dev-secret-key" + expires_ttl: 86400 + +log: + log_level: "debug" + log_format: "text" + +s3: + region: "us-east-1" + bucket: "local-bucket" + access_key_id: "" + secret_access_key: "" + +dukcapil: + base_url: "http://172.16.160.176:8080/api/face-recognition" + customer_id: "" + methode: "CALL_FN" + user_id: "" + password: "" + public_key_path: "infra/dukcapil_public.pem" + default_ip: "127.0.0.1" + timeout_second: 30 diff --git a/internal/app/app.go b/internal/app/app.go index 3dbd8d6..f2b0c82 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,69 +9,39 @@ import ( "syscall" "time" - "eslogad-be/config" - "eslogad-be/internal/client" - internalConfig "eslogad-be/internal/config" - "eslogad-be/internal/handler" - "eslogad-be/internal/middleware" - "eslogad-be/internal/processor" - "eslogad-be/internal/repository" - "eslogad-be/internal/router" - "eslogad-be/internal/service" - "eslogad-be/internal/validator" - - "gorm.io/gorm" + "go-backend-template/config" + "go-backend-template/internal/client" + "go-backend-template/internal/handler" + "go-backend-template/internal/router" + "go-backend-template/internal/service" ) type App struct { server *http.Server - db *gorm.DB router *router.Router shutdown chan os.Signal } -func NewApp(db *gorm.DB) *App { +func NewApp() *App { return &App{ - db: db, shutdown: make(chan os.Signal, 1), } } func (a *App) Initialize(cfg *config.Config) error { - repos := a.initRepositories() - processors := a.initProcessors(cfg, repos) - services := a.initServices(processors, repos, cfg) - middlewares := a.initMiddleware(services) healthHandler := handler.NewHealthHandler() - fileHandler := handler.NewFileHandler(services.fileService) - rbacHandler := handler.NewRBACHandler(services.rbacService) - masterHandler := handler.NewMasterHandler(services.masterService) - letterHandler := handler.NewLetterHandler(services.letterService) - letterOutgoingHandler := handler.NewLetterOutgoingHandler(services.letterOutgoingService) - adminApprovalFlowHandler := handler.NewAdminApprovalFlowHandler(services.approvalFlowService) - dispositionRouteHandler := handler.NewDispositionRouteHandler(services.dispositionRouteService) - onlyOfficeHandler := handler.NewOnlyOfficeHandler(services.onlyOfficeService) - analyticsHandler := handler.NewAnalyticsHandler(services.analyticsService) - notificationHandler := handler.NewNotificationHandler(services.notificationService) - repositoryAttachmentHandler := handler.NewRepositoryAttachmentHandler(services.repositoryAttachmentService) + + dukcapilClient := client.NewDukcapilClient(cfg.Dukcapil) + dukcapilService := service.NewDukcapilService(dukcapilClient) + dukcapilHandler := handler.NewDukcapilHandler(dukcapilService) a.router = router.NewRouter( cfg, - handler.NewAuthHandler(services.authService), - middlewares.authMiddleware, + nil, // authHandler + nil, // authMiddleware healthHandler, - handler.NewUserHandler(services.userService, validator.NewUserValidator()), - fileHandler, - rbacHandler, - masterHandler, - letterHandler, - letterOutgoingHandler, - adminApprovalFlowHandler, - dispositionRouteHandler, - onlyOfficeHandler, - analyticsHandler, - notificationHandler, - repositoryAttachmentHandler, + nil, // userHandler + dukcapilHandler, ) return nil @@ -80,8 +50,20 @@ func (a *App) Initialize(cfg *config.Config) error { func (a *App) Start(port string) error { engine := a.router.Init() + // Debug: log what port value we received + log.Printf("DEBUG: Received port value: %q", port) + + // Ensure proper address format + // Only accept port number, not full addresses + addr := ":" + port + if port == "" { + addr = ":8080" // default port + } + + log.Printf("DEBUG: Final address: %q", addr) + a.server = &http.Server{ - Addr: ":" + port, + Addr: addr, Handler: engine, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, @@ -115,362 +97,3 @@ func (a *App) Start(port string) error { func (a *App) Shutdown() { close(a.shutdown) } - -type repositories struct { - userRepo *repository.UserRepositoryImpl - userProfileRepo *repository.UserProfileRepository - titleRepo *repository.TitleRepository - rbacRepo *repository.RBACRepository - labelRepo *repository.LabelRepository - priorityRepo *repository.PriorityRepository - institutionRepo *repository.InstitutionRepository - dispRepo *repository.DispositionActionRepository - letterRepo *repository.LetterIncomingRepository - letterAttachRepo *repository.LetterIncomingAttachmentRepository - activityLogRepo *repository.LetterIncomingActivityLogRepository - dispositionRouteRepo *repository.DispositionRouteRepository - // new repos - letterDispositionRepo *repository.LetterIncomingDispositionRepository - letterDispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository - letterDispActionSelRepo *repository.LetterDispositionActionSelectionRepository - dispositionNoteRepo *repository.DispositionNoteRepository - letterDiscussionRepo *repository.LetterDiscussionRepository - settingRepo *repository.AppSettingRepository - recipientRepo *repository.LetterIncomingRecipientRepository - departmentRepo *repository.DepartmentRepository - userDeptRepo *repository.UserDepartmentRepository - // letter outgoing repos - letterOutgoingRepo *repository.LetterOutgoingRepository - letterOutgoingAttachmentRepo *repository.LetterOutgoingAttachmentRepository - letterOutgoingFinalAttachmentRepo *repository.LetterOutgoingFinalAttachmentRepository - letterOutgoingRecipientRepo *repository.LetterOutgoingRecipientRepository - letterOutgoingDiscussionRepo *repository.LetterOutgoingDiscussionRepository - letterOutgoingDiscussionAttachRepo *repository.LetterOutgoingDiscussionAttachmentRepository - letterOutgoingActivityLogRepo *repository.LetterOutgoingActivityLogRepository - approvalFlowRepo *repository.ApprovalFlowRepository - letterOutgoingApprovalRepo *repository.LetterOutgoingApprovalRepository - analyticsRepo *repository.AnalyticsRepository - repositoryAttachmentRepo *repository.RepositoryAttachmentRepositoryImpl -} - -func (a *App) initRepositories() *repositories { - return &repositories{ - userRepo: repository.NewUserRepository(a.db), - userProfileRepo: repository.NewUserProfileRepository(a.db), - titleRepo: repository.NewTitleRepository(a.db), - rbacRepo: repository.NewRBACRepository(a.db), - labelRepo: repository.NewLabelRepository(a.db), - priorityRepo: repository.NewPriorityRepository(a.db), - institutionRepo: repository.NewInstitutionRepository(a.db), - dispRepo: repository.NewDispositionActionRepository(a.db), - letterRepo: repository.NewLetterIncomingRepository(a.db), - letterAttachRepo: repository.NewLetterIncomingAttachmentRepository(a.db), - activityLogRepo: repository.NewLetterIncomingActivityLogRepository(a.db), - dispositionRouteRepo: repository.NewDispositionRouteRepository(a.db), - letterDispositionRepo: repository.NewLetterIncomingDispositionRepository(a.db), - letterDispositionDeptRepo: repository.NewLetterIncomingDispositionDepartmentRepository(a.db), - letterDispActionSelRepo: repository.NewLetterDispositionActionSelectionRepository(a.db), - dispositionNoteRepo: repository.NewDispositionNoteRepository(a.db), - letterDiscussionRepo: repository.NewLetterDiscussionRepository(a.db), - settingRepo: repository.NewAppSettingRepository(a.db), - recipientRepo: repository.NewLetterIncomingRecipientRepository(a.db), - departmentRepo: repository.NewDepartmentRepository(a.db), - userDeptRepo: repository.NewUserDepartmentRepository(a.db), - letterOutgoingRepo: repository.NewLetterOutgoingRepository(a.db), - letterOutgoingAttachmentRepo: repository.NewLetterOutgoingAttachmentRepository(a.db), - letterOutgoingFinalAttachmentRepo: repository.NewLetterOutgoingFinalAttachmentRepository(a.db), - letterOutgoingRecipientRepo: repository.NewLetterOutgoingRecipientRepository(a.db), - letterOutgoingDiscussionRepo: repository.NewLetterOutgoingDiscussionRepository(a.db), - letterOutgoingDiscussionAttachRepo: repository.NewLetterOutgoingDiscussionAttachmentRepository(a.db), - letterOutgoingActivityLogRepo: repository.NewLetterOutgoingActivityLogRepository(a.db), - approvalFlowRepo: repository.NewApprovalFlowRepository(a.db), - letterOutgoingApprovalRepo: repository.NewLetterOutgoingApprovalRepository(a.db), - analyticsRepo: repository.NewAnalyticsRepository(a.db), - repositoryAttachmentRepo: repository.NewRepositoryAttachmentRepositoryImpl(a.db), - } -} - -type processors struct { - 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 - // Modular processors for letter outgoing - letterValidationProcessor processor.LetterValidationProcessor - letterCreationProcessor processor.LetterCreationProcessor - letterApprovalProcessor processor.LetterApprovalProcessor - letterAttachmentProcessor processor.LetterAttachmentProcessor - letterOutgoingRecipientProcessor processor.LetterOutgoingRecipientProcessor - letterActivityProcessor processor.LetterActivityProcessor - repositoryAttachmentProcessor *processor.RepositoryAttachmentProcessorImpl - txManager *repository.TxManager -} - -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.letterOutgoingRecipientRepo, - repos.departmentRepo, repos.userDeptRepo, - repos.priorityRepo, repos.institutionRepo, repos.dispRepo, - letterNumberGen, repos.dispositionRouteRepo, - ) - - // Create modular processors for letter outgoing - letterValidationProc := processor.NewLetterValidationProcessor() - letterCreationProc := processor.NewLetterCreationProcessor( - repos.letterOutgoingRepo, - repos.approvalFlowRepo, - letterNumberGen, - ) - letterApprovalProc := processor.NewLetterApprovalProcessor( - repos.letterOutgoingApprovalRepo, - repos.approvalFlowRepo, - repos.letterOutgoingRepo, - ) - letterAttachmentProc := processor.NewLetterAttachmentProcessor( - repos.letterOutgoingAttachmentRepo, - ) - letterOutgoingRecipientProc := processor.NewLetterOutgoingRecipientProcessor( - repos.letterOutgoingRecipientRepo, - repos.approvalFlowRepo, - repos.userDeptRepo, - ) - letterActivityProc := processor.NewLetterActivityProcessor( - repos.letterOutgoingActivityLogRepo, - ) - - // Create the main letter outgoing processor for backward compatibility - letterOutgoingProc := processor.NewLetterOutgoingProcessor( - a.db, - repos.letterOutgoingRepo, - repos.letterOutgoingAttachmentRepo, - repos.letterOutgoingFinalAttachmentRepo, - repos.letterOutgoingRecipientRepo, - repos.letterOutgoingDiscussionRepo, - repos.letterOutgoingDiscussionAttachRepo, - repos.letterOutgoingActivityLogRepo, - repos.approvalFlowRepo, - 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, - docSessionRepo, - docVersionRepo, - docMetadataRepo, - 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 role processor - userRoleProc := processor.NewUserRoleProcessor(a.db) - - // Create user processor with Novu integration - userProc := processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo, userRoleProc) - userProc.SetNovuProcessor(novuProc) - - // Create cached user processor for auth middleware - cachedUserProc := processor.NewCachedUserProcessor(repos.userRepo, repos.userProfileRepo, userRoleProc) - - // 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, - ) - - repositoryAttachmentProc := processor.NewRepositoryAttachmentProcessor( - repos.repositoryAttachmentRepo) - - return &processors{ - userProcessor: userProc, - cachedUserProcessor: cachedUserProc, - letterProcessor: letterProc, - letterOutgoingProcessor: letterOutgoingProc, - activityLogger: activity, - letterNumberGenerator: letterNumberGen, - onlyOfficeProcessor: onlyOfficeProc, - novuProcessor: novuProc, - notificationProcessor: notificationProc, - recipientProcessor: recipientProc, - letterDispositionProcessor: letterDispositionProc, - letterDispositionDeptProcessor: letterDispositionDeptProc, - // Modular processors - letterValidationProcessor: letterValidationProc, - letterCreationProcessor: letterCreationProc, - letterApprovalProcessor: letterApprovalProc, - letterAttachmentProcessor: letterAttachmentProc, - letterOutgoingRecipientProcessor: letterOutgoingRecipientProc, - letterActivityProcessor: letterActivityProc, - repositoryAttachmentProcessor: repositoryAttachmentProc, - txManager: txMgr, - } -} - -type services struct { - userService *service.UserServiceImpl - authService *service.AuthServiceImpl - fileService *service.FileServiceImpl - rbacService *service.RBACServiceImpl - masterService *service.MasterServiceImpl - letterService *service.LetterServiceImpl - letterOutgoingService *service.LetterOutgoingServiceImpl - approvalFlowService *service.ApprovalFlowServiceImpl - dispositionRouteService *service.DispositionRouteServiceImpl - onlyOfficeService *service.OnlyOfficeServiceImpl - analyticsService *service.AnalyticsServiceImpl - notificationService *service.NotificationServiceImpl - repositoryAttachmentService *service.RepositoryAttachmentServiceImpl -} - -func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { - authConfig := cfg.Auth() - jwtSecret := authConfig.AccessTokenSecret() - authService := service.NewAuthService(processors.userProcessor, jwtSecret) - - userSvc := service.NewUserService(processors.userProcessor, repos.titleRepo) - - fileCfg := cfg.S3Config - s3Client := client.NewFileClient(fileCfg) - fileSvc := service.NewFileService(s3Client, processors.userProcessor, "profile", "documents", "finals") - - rbacSvc := service.NewRBACService(repos.rbacRepo) - - masterSvc := service.NewMasterService(repos.labelRepo, repos.priorityRepo, repos.institutionRepo, repos.dispRepo, repos.departmentRepo, cfg) - - txManager := repository.NewTxManager(a.db) - letterSvc := service.NewLetterService( - processors.letterProcessor, - txManager, - processors.letterNumberGenerator, - processors.recipientProcessor, - processors.activityLogger, - processors.letterDispositionProcessor, - processors.notificationProcessor, - processors.activityLogger, - ) - dispRouteSvc := service.NewDispositionRouteService(repos.dispositionRouteRepo) - letterOutgoingSvc := service.NewLetterOutgoingService( - processors.letterOutgoingProcessor, - processors.txManager, - processors.letterValidationProcessor, - processors.letterCreationProcessor, - processors.letterApprovalProcessor, - processors.letterAttachmentProcessor, - processors.letterOutgoingRecipientProcessor, - processors.notificationProcessor, - processors.letterActivityProcessor, - ) - - approvalFlowStepRepo := repository.NewApprovalFlowStepRepository(a.db) - approvalFlowSvc := service.NewApprovalFlowService( - a.db, - repos.approvalFlowRepo, - approvalFlowStepRepo, - txManager, - ) - - // 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) - - repositoryAttachmentSvc := service.NewRepositoryAttachmentService(processors.repositoryAttachmentProcessor) - - return &services{ - userService: userSvc, - authService: authService, - fileService: fileSvc, - rbacService: rbacSvc, - masterService: masterSvc, - letterService: letterSvc, - letterOutgoingService: letterOutgoingSvc, - approvalFlowService: approvalFlowSvc, - dispositionRouteService: dispRouteSvc, - onlyOfficeService: onlyOfficeSvc, - analyticsService: analyticsSvc, - notificationService: notificationSvc, - repositoryAttachmentService: repositoryAttachmentSvc, - } -} - -type middlewares struct { - authMiddleware *middleware.AuthMiddleware -} - -func (a *App) initMiddleware(services *services) *middlewares { - return &middlewares{ - authMiddleware: middleware.NewAuthMiddleware(services.authService), - } -} - -type validators struct { - userValidator *validator.UserValidatorImpl -} - -func (a *App) initValidators() *validators { - return &validators{ - userValidator: validator.NewUserValidator(), - } -} diff --git a/internal/client/dukcapil_client.go b/internal/client/dukcapil_client.go new file mode 100644 index 0000000..263dcfa --- /dev/null +++ b/internal/client/dukcapil_client.go @@ -0,0 +1,168 @@ +package client + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" + + "go-backend-template/config" + "go-backend-template/internal/contract" + "go-backend-template/internal/logger" +) + +// DukcapilClient performs HTTPS calls to the Dukcapil 1:N face recognition +// endpoint (CALL_FN). It loads and caches the configured RSA public key used +// to encrypt sensitive credentials. +type DukcapilClient struct { + cfg config.Dukcapil + http *http.Client + pubKey *rsa.PublicKey + keyMu sync.Mutex +} + +func NewDukcapilClient(cfg config.Dukcapil) *DukcapilClient { + return &DukcapilClient{ + cfg: cfg, + http: &http.Client{ + Timeout: cfg.Timeout(), + }, + } +} + +// FaceMatch performs a 1:N face match call. user_id/password are encrypted +// with the configured public key (RSA PKCS1v15 -> base64). The image must +// already be base64 encoded. +func (c *DukcapilClient) FaceMatch(ctx context.Context, req *contract.FaceMatchRequest) (*contract.DukcapilFaceResponse, error) { + if c.cfg.BaseURL == "" || c.cfg.CustomerID == "" || c.cfg.Methode == "" { + return nil, errors.New("dukcapil: incomplete configuration") + } + + pub, err := c.loadPublicKey() + if err != nil { + return nil, fmt.Errorf("dukcapil: load public key: %w", err) + } + + encUserID, err := encryptAndEncode(pub, []byte(c.cfg.UserID)) + if err != nil { + return nil, fmt.Errorf("dukcapil: encrypt user_id: %w", err) + } + encPassword, err := encryptAndEncode(pub, []byte(c.cfg.Password)) + if err != nil { + return nil, fmt.Errorf("dukcapil: encrypt password: %w", err) + } + + ip := req.IP + if strings.TrimSpace(ip) == "" { + ip = c.cfg.DefaultIP + } + + body := contract.DukcapilFaceRequest{ + TransactionID: req.TransactionID, + TransactionSource: req.TransactionSource, + Threshold: req.Threshold, + Image: req.Image, + UserID: encUserID, + Password: encPassword, + IP: ip, + } + + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("dukcapil: marshal request: %w", err) + } + + url := fmt.Sprintf("%s/%s/%s", + strings.TrimRight(c.cfg.BaseURL, "/"), + c.cfg.CustomerID, + c.cfg.Methode, + ) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("dukcapil: build request: %w", err) + } + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Content-Type", "application/json") + + start := time.Now() + resp, err := c.http.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("dukcapil: do request: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("dukcapil: read response: %w", err) + } + + logger.FromContext(ctx).Infof("DukcapilClient::FaceMatch -> status=%d duration=%s", resp.StatusCode, time.Since(start)) + + if resp.StatusCode >= 500 { + return nil, fmt.Errorf("dukcapil: upstream status %d: %s", resp.StatusCode, string(respBytes)) + } + + var out contract.DukcapilFaceResponse + if err := json.Unmarshal(respBytes, &out); err != nil { + return nil, fmt.Errorf("dukcapil: decode response: %w (body=%s)", err, string(respBytes)) + } + return &out, nil +} + +func (c *DukcapilClient) loadPublicKey() (*rsa.PublicKey, error) { + c.keyMu.Lock() + defer c.keyMu.Unlock() + if c.pubKey != nil { + return c.pubKey, nil + } + if c.cfg.PublicKeyPath == "" { + return nil, errors.New("public key path not configured") + } + raw, err := os.ReadFile(c.cfg.PublicKeyPath) + if err != nil { + return nil, err + } + block, _ := pem.Decode(raw) + if block == nil { + return nil, errors.New("invalid PEM file") + } + + // Try PKIX first (BEGIN PUBLIC KEY) then PKCS1 (BEGIN RSA PUBLIC KEY). + if pub, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil { + rsaPub, ok := pub.(*rsa.PublicKey) + if !ok { + return nil, errors.New("public key is not RSA") + } + c.pubKey = rsaPub + return rsaPub, nil + } + rsaPub, err := x509.ParsePKCS1PublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse rsa public key: %w", err) + } + c.pubKey = rsaPub + return rsaPub, nil +} + +// encryptAndEncode mimics PHP openssl_public_encrypt (default padding = +// PKCS1v15) and base64-encodes the ciphertext. +func encryptAndEncode(pub *rsa.PublicKey, plaintext []byte) (string, error) { + cipherBytes, err := rsa.EncryptPKCS1v15(rand.Reader, pub, plaintext) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(cipherBytes), nil +} diff --git a/internal/config/novu.go b/internal/config/novu.go deleted file mode 100644 index 285d7f0..0000000 --- a/internal/config/novu.go +++ /dev/null @@ -1,30 +0,0 @@ -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/constants/error.go b/internal/constants/error.go index 4de95bb..1bdd51b 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -39,6 +39,8 @@ const ( PaymentMethodHandlerEntity = "payment_method_handler" OutletServiceEntity = "outlet_service" TableEntity = "table" + DukcapilHandlerEntity = "dukcapil_handler" + DukcapilServiceEntity = "dukcapil_service" ) var HttpErrorMap = map[string]int{ diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go deleted file mode 100644 index 8eededa..0000000 --- a/internal/contract/analytics_contract.go +++ /dev/null @@ -1,186 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -// AnalyticsDashboardRequest represents the request for analytics data -type AnalyticsDashboardRequest struct { - StartDate string `form:"start_date" json:"start_date"` - EndDate string `form:"end_date" json:"end_date"` - DepartmentID *uuid.UUID `form:"department_id" json:"department_id,omitempty"` - UserID *uuid.UUID `form:"user_id" json:"user_id,omitempty"` -} - -// AnalyticsDashboardResponse represents the complete analytics dashboard -type AnalyticsDashboardResponse struct { - Summary LetterSummaryStats `json:"summary"` - PriorityDistribution []PriorityDistribution `json:"priority_distribution"` - DepartmentStats []DepartmentStats `json:"department_stats"` - MonthlyTrend []MonthlyTrend `json:"monthly_trend"` - DepartmentsStats []SimpleDepartmentStats `json:"departments_stats,omitempty"` - InstitutionStats []InstitutionStats `json:"institution_stats"` - DailyActivity []DailyActivity `json:"daily_activity"` - StatusDistribution []StatusDistribution `json:"status_distribution,omitempty"` - TopSenders []TopUserStats `json:"top_senders,omitempty"` - TopRecipients []TopUserStats `json:"top_recipients,omitempty"` - ApprovalMetrics *ApprovalMetrics `json:"approval_metrics,omitempty"` - ResponseTimeStats *ResponseTimeStats `json:"response_time_stats,omitempty"` -} - -// LetterSummaryStats represents overall summary statistics -type LetterSummaryStats struct { - TotalIncoming int64 `json:"total_incoming"` - TotalOutgoing int64 `json:"total_outgoing"` - WeekOverWeekGrowth float64 `json:"week_over_week_growth"` - MonthOverMonthGrowth float64 `json:"month_over_month_growth"` - TotalThisWeek float64 `json:"total_this_week"` - TotalPending int64 `json:"total_pending,omitempty"` - TotalApproved int64 `json:"total_approved,omitempty"` - TotalRejected int64 `json:"total_rejected,omitempty"` - TotalArchived int64 `json:"total_archived,omitempty"` - AvgProcessingTime float64 `json:"avg_processing_time_hours,omitempty"` - CompletionRate float64 `json:"completion_rate,omitempty"` -} - -// StatusDistribution represents letter distribution by status -type StatusDistribution struct { - Status string `json:"status"` - Count int64 `json:"count"` - Percentage float64 `json:"percentage"` - Type string `json:"type"` // incoming or outgoing -} - -// PriorityDistribution represents letter distribution by priority -type PriorityDistribution struct { - PriorityID string `json:"priority_id"` - PriorityName string `json:"priority_name"` - Level int `json:"level"` - Count int64 `json:"count"` - Percentage float64 `json:"percentage"` - AvgResponseTime float64 `json:"avg_response_time_hours"` -} - -// DepartmentStats represents statistics per department -type DepartmentStats struct { - DepartmentID uuid.UUID `json:"department_id"` - DepartmentName string `json:"department_name"` - DepartmentCode string `json:"department_code"` - IncomingCount int64 `json:"incoming_count"` - OutgoingCount int64 `json:"outgoing_count"` - PendingCount int64 `json:"pending_count"` - AvgResponseTime float64 `json:"avg_response_time_hours"` - CompletionRate float64 `json:"completion_rate"` -} - -// MonthlyTrend represents monthly trend data -type MonthlyTrend struct { - Month string `json:"month"` - Year int `json:"year"` - IncomingCount int64 `json:"incoming_count"` - OutgoingCount int64 `json:"outgoing_count"` - TotalCount int64 `json:"total_count"` - GrowthRate float64 `json:"growth_rate"` -} - -// TopUserStats represents top users by letter activity -type TopUserStats struct { - UserID uuid.UUID `json:"user_id"` - UserName string `json:"user_name"` - UserEmail string `json:"user_email"` - Department string `json:"department"` - LetterCount int64 `json:"letter_count"` - AvgResponseTime float64 `json:"avg_response_time_hours"` -} - -// InstitutionStats represents statistics per institution -type InstitutionStats struct { - InstitutionID uuid.UUID `json:"institution_id"` - InstitutionName string `json:"institution_name"` - InstitutionType string `json:"institution_type"` - IncomingCount int64 `json:"incoming_count"` - OutgoingCount int64 `json:"outgoing_count"` - TotalCount int64 `json:"total_count"` - LastActivity time.Time `json:"last_activity"` -} - -// ApprovalMetrics represents approval-related metrics -type ApprovalMetrics struct { - TotalSubmitted int64 `json:"total_submitted"` - TotalApproved int64 `json:"total_approved"` - TotalRejected int64 `json:"total_rejected"` - TotalPending int64 `json:"total_pending"` - ApprovalRate float64 `json:"approval_rate"` - RejectionRate float64 `json:"rejection_rate"` - AvgApprovalTime float64 `json:"avg_approval_time_hours"` - AvgApprovalSteps float64 `json:"avg_approval_steps"` - BottleneckSteps []BottleneckStep `json:"bottleneck_steps"` -} - -// BottleneckStep represents approval steps that cause delays -type BottleneckStep struct { - StepOrder int `json:"step_order"` - ApproverName string `json:"approver_name"` - AvgProcessTime float64 `json:"avg_process_time_hours"` - PendingCount int64 `json:"pending_count"` -} - -// ResponseTimeStats represents response time statistics -type ResponseTimeStats struct { - MinResponseTime float64 `json:"min_response_time_hours"` - MaxResponseTime float64 `json:"max_response_time_hours"` - AvgResponseTime float64 `json:"avg_response_time_hours"` - MedianResponseTime float64 `json:"median_response_time_hours"` - P95ResponseTime float64 `json:"p95_response_time_hours"` - P99ResponseTime float64 `json:"p99_response_time_hours"` -} - -// SimpleDepartmentStats represents simplified department statistics -type SimpleDepartmentStats struct { - DepartmentID uuid.UUID `json:"department_id"` - Department string `json:"department"` - LetterCount int64 `json:"letter_count"` -} - -// DailyActivity represents daily activity data -type DailyActivity struct { - Date string `json:"date"` - DayOfWeek string `json:"day_of_week"` - IncomingCount int64 `json:"incoming_count"` - OutgoingCount int64 `json:"outgoing_count"` - ApprovedCount int64 `json:"approved_count,omitempty"` - RejectedCount int64 `json:"rejected_count,omitempty"` - HourlyDistribution []HourlyActivity `json:"hourly_distribution,omitempty"` -} - -// HourlyActivity represents hourly activity within a day -type HourlyActivity struct { - Hour int `json:"hour"` - Count int64 `json:"count"` -} - -// LetterVolumeByTypeResponse represents letter volume grouped by type -type LetterVolumeByTypeResponse struct { - Incoming IncomingLetterVolume `json:"incoming"` - Outgoing OutgoingLetterVolume `json:"outgoing"` -} - -// IncomingLetterVolume represents incoming letter statistics -type IncomingLetterVolume struct { - Today int64 `json:"today"` - ThisWeek int64 `json:"this_week"` - ThisMonth int64 `json:"this_month"` - ThisYear int64 `json:"this_year"` - Total int64 `json:"total"` -} - -// OutgoingLetterVolume represents outgoing letter statistics -type OutgoingLetterVolume struct { - Today int64 `json:"today"` - ThisWeek int64 `json:"this_week"` - ThisMonth int64 `json:"this_month"` - ThisYear int64 `json:"this_year"` - Total int64 `json:"total"` -} diff --git a/internal/contract/disposition_route_contract.go b/internal/contract/disposition_route_contract.go deleted file mode 100644 index 3827108..0000000 --- a/internal/contract/disposition_route_contract.go +++ /dev/null @@ -1,73 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -type DepartmentInfo struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Code string `json:"code,omitempty"` -} - -type DispositionRouteResponse struct { - ID uuid.UUID `json:"id"` - FromDepartmentID uuid.UUID `json:"from_department_id"` - ToDepartmentID uuid.UUID `json:"to_department_id"` - IsActive bool `json:"is_active"` - AllowedActions map[string]interface{} `json:"allowed_actions,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - // Department information - FromDepartment DepartmentInfo `json:"from_department"` - ToDepartment DepartmentInfo `json:"to_department"` -} - -type CreateDispositionRouteRequest struct { - FromDepartmentID uuid.UUID `json:"from_department_id" 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"` -} - -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/dukcapil_contract.go b/internal/contract/dukcapil_contract.go new file mode 100644 index 0000000..fd30783 --- /dev/null +++ b/internal/contract/dukcapil_contract.go @@ -0,0 +1,66 @@ +package contract + +// FaceMatchRequest is the inbound payload from clients of this service to +// trigger a Dukcapil 1:N face recognition lookup. +// +// Image must already be a base64 (no data:image prefix) representation of a +// jpg/png file. Threshold is forwarded to Dukcapil (1..20). IP is optional; +// when empty the configured default IP will be used. +type FaceMatchRequest struct { + TransactionID string `json:"transaction_id" validate:"required,max=20"` + TransactionSource string `json:"transaction_source" validate:"required,max=50"` + Threshold string `json:"threshold" validate:"required"` + Image string `json:"image" validate:"required"` + IP string `json:"ip,omitempty"` +} + +// DukcapilFaceRequest is the exact JSON body sent to the Dukcapil +// face-recognition endpoint (CALL_FN). user_id and password are RSA encrypted +// with the provided public key and base64 encoded. +type DukcapilFaceRequest struct { + TransactionID string `json:"transactionId"` + TransactionSource string `json:"transactionSource"` + Threshold string `json:"threshold"` + Image string `json:"image"` + UserID string `json:"user_id"` + Password string `json:"password"` + IP string `json:"ip"` +} + +// DukcapilFaceResponse is the standard Dukcapil response envelope (success + +// most error variants share this shape). Some error variants use a different +// shape – clients should also inspect ErrorCode. +type DukcapilFaceResponse struct { + TID string `json:"tid"` + EncounterID interface{} `json:"encounter_id"` + Error string `json:"error"` + ErrorCode string `json:"errorCode"` + FingerData interface{} `json:"finger_data"` + IrisData interface{} `json:"iris_data"` + FaceData interface{} `json:"face_data"` + RequestType string `json:"request_type"` + MaxResults int `json:"maxResults"` + FaceThreshold string `json:"faceThreshold"` + FingerThreshold int `json:"fingerThreshold"` + IrisThreshold int `json:"irisThreshold"` + Response string `json:"response"` +} + +// FaceMatchResponse is what we return to our API consumers. +type FaceMatchResponse struct { + TID string `json:"tid"` + ErrorCode string `json:"error_code"` + Error string `json:"error"` + RequestType string `json:"request_type"` + Threshold string `json:"threshold"` + MaxResults int `json:"max_results"` + Matches []FaceMatchResult `json:"matches"` + Raw *DukcapilFaceResponse `json:"raw,omitempty"` +} + +// FaceMatchResult represents a single (NIK -> score) entry from the Dukcapil +// `response.face.FACE_T5` map. +type FaceMatchResult struct { + NIK string `json:"nik"` + Score float64 `json:"score"` +} diff --git a/internal/contract/letter_contract.go b/internal/contract/letter_contract.go deleted file mode 100644 index 8960054..0000000 --- a/internal/contract/letter_contract.go +++ /dev/null @@ -1,291 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -type SearchIncomingLettersRequest struct { - Query string `json:"query" form:"query"` - LetterNumber string `json:"letter_number" form:"letter_number"` - Subject string `json:"subject" form:"subject"` - Status string `json:"status" form:"status"` - PriorityID *uuid.UUID `json:"priority_id" form:"priority_id"` - InstitutionID *uuid.UUID `json:"institution_id" form:"institution_id"` - CreatedBy *uuid.UUID `json:"created_by" form:"created_by"` - DateFrom *time.Time `json:"date_from" form:"date_from"` - DateTo *time.Time `json:"date_to" form:"date_to"` - Page int `json:"page" form:"page"` - Limit int `json:"limit" form:"limit"` - SortBy string `json:"sort_by" form:"sort_by"` - SortOrder string `json:"sort_order" form:"sort_order"` -} - -type SearchIncomingLettersResponse struct { - Letters []IncomingLetterResponse `json:"letters"` - TotalCount int64 `json:"total_count"` - Page int `json:"page"` - Limit int `json:"limit"` -} - -type CreateIncomingLetterAttachment struct { - FileURL string `json:"file_url"` - FileName string `json:"file_name"` - FileType string `json:"file_type"` -} - -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"` - PriorityID *uuid.UUID `json:"priority_id,omitempty"` - SenderInstitutionID *uuid.UUID `json:"sender_institution_id,omitempty"` - SenderName *string `json:"sender_name,omitempty"` - Addressee *string `json:"addressee,omitempty"` - ReceivedDate time.Time `json:"received_date"` - DueDate *time.Time `json:"due_date,omitempty"` - Type string `json:"type"` // UTAMA or TEMBUSAN - Attachments []CreateIncomingLetterAttachment `json:"attachments,omitempty"` -} - -type IncomingLetterAttachmentResponse struct { - ID uuid.UUID `json:"id"` - FileURL string `json:"file_url"` - FileName string `json:"file_name"` - FileType string `json:"file_type"` - UploadedAt time.Time `json:"uploaded_at"` -} - -type IncomingLetterResponse struct { - ID uuid.UUID `json:"id"` - LetterNumber string `json:"letter_number"` - ReferenceNumber *string `json:"reference_number,omitempty"` - Subject string `json:"subject"` - Description *string `json:"description,omitempty"` - Priority *PriorityResponse `json:"priority,omitempty"` - SenderInstitution *InstitutionResponse `json:"sender_institution,omitempty"` - SenderName *string `json:"sender_name,omitempty"` - Addressee *string `json:"addressee,omitempty"` - ReceivedDate time.Time `json:"received_date"` - DueDate *time.Time `json:"due_date,omitempty"` - Type string `json:"type"` - Status string `json:"status"` - CreatedBy uuid.UUID `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Attachments []IncomingLetterAttachmentResponse `json:"attachments"` - IsRead bool `json:"is_read"` - Dispositions []EnhancedDispositionResponse `json:"dispositions"` -} - -type UpdateIncomingLetterRequest struct { - ReferenceNumber *string `json:"reference_number,omitempty"` - Subject *string `json:"subject,omitempty"` - Description *string `json:"description,omitempty"` - PriorityID *uuid.UUID `json:"priority_id,omitempty"` - SenderInstitutionID *uuid.UUID `json:"sender_institution_id,omitempty"` - SenderName *string `json:"sender_name,omitempty"` - Addressee *string `json:"addressee,omitempty"` - ReceivedDate *time.Time `json:"received_date,omitempty"` - DueDate *time.Time `json:"due_date,omitempty"` - Type *string `json:"type,omitempty"` - Status *string `json:"status,omitempty"` -} - -type ListIncomingLettersRequest struct { - Page int `json:"page"` - Limit int `json:"limit"` - Status *string `json:"status,omitempty"` - Query *string `json:"query,omitempty"` - 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"` - 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 { - ActionID uuid.UUID `json:"action_id"` - Note *string `json:"note,omitempty"` -} - -type CreateLetterDispositionRequest struct { - FromDepartment uuid.UUID `json:"from_department"` - LetterID uuid.UUID `json:"letter_id"` - ToDepartmentIDs []uuid.UUID `json:"to_department_ids"` - Notes *string `json:"notes,omitempty"` - SelectedActions []CreateDispositionActionSelection `json:"selected_actions,omitempty"` - CreatedBy uuid.UUID `json:"created_by"` -} - -type DispositionResponse struct { - ID uuid.UUID `json:"id"` - LetterID uuid.UUID `json:"letter_id"` - DepartmentID *uuid.UUID `json:"department_id,omitempty"` - Notes *string `json:"notes,omitempty"` - ReadAt *time.Time `json:"read_at,omitempty"` - CreatedBy uuid.UUID `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListDispositionsResponse struct { - Dispositions []DispositionResponse `json:"dispositions"` -} - -type EnhancedDispositionResponse struct { - ID uuid.UUID `json:"id"` - LetterID uuid.UUID `json:"letter_id"` - DepartmentID *uuid.UUID `json:"department_id,omitempty"` - Notes *string `json:"notes,omitempty"` - ReadAt *time.Time `json:"read_at,omitempty"` - CreatedBy uuid.UUID `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Department DepartmentResponse `json:"department"` - Departments []DispositionDepartmentResponse `json:"departments"` - Actions []DispositionActionSelectionResponse `json:"actions"` - DispositionNotes []DispositionNoteResponse `json:"disposition_notes"` -} - -type DispositionDepartmentResponse struct { - ID uuid.UUID `json:"id"` - DepartmentID uuid.UUID `json:"department_id"` - CreatedAt time.Time `json:"created_at"` - Department *DepartmentResponse `json:"department,omitempty"` -} - -type DispositionActionSelectionResponse struct { - ID uuid.UUID `json:"id"` - ActionID uuid.UUID `json:"action_id"` - Action *DispositionActionResponse `json:"action,omitempty"` - Note *string `json:"note,omitempty"` - CreatedBy uuid.UUID `json:"created_by"` - CreatedAt time.Time `json:"created_at"` -} - -type DispositionNoteResponse struct { - ID uuid.UUID `json:"id"` - UserID *uuid.UUID `json:"user_id,omitempty"` - Note string `json:"note"` - CreatedAt time.Time `json:"created_at"` - User *UserResponse `json:"user,omitempty"` -} - -type ListEnhancedDispositionsResponse struct { - Dispositions []EnhancedDispositionResponse `json:"dispositions"` - Discussions []LetterDiscussionResponse `json:"discussions"` -} - -type 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"` - Mentions map[string]interface{} `json:"mentions,omitempty"` -} - -type UpdateLetterDiscussionRequest struct { - Message string `json:"message"` - Mentions map[string]interface{} `json:"mentions,omitempty"` -} - -type LetterDiscussionResponse struct { - ID uuid.UUID `json:"id"` - LetterID uuid.UUID `json:"letter_id"` - ParentID *uuid.UUID `json:"parent_id,omitempty"` - UserID uuid.UUID `json:"user_id"` - Message string `json:"message"` - Mentions map[string]interface{} `json:"mentions,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - EditedAt *time.Time `json:"edited_at,omitempty"` - - // Preloaded user profile who created the discussion - User *UserResponse `json:"user,omitempty"` - - // Preloaded user profiles for mentions - MentionedUsers []UserResponse `json:"mentioned_users,omitempty"` -} - -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 deleted file mode 100644 index ece9a40..0000000 --- a/internal/contract/letter_outgoing_contract.go +++ /dev/null @@ -1,385 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -type SearchOutgoingLettersRequest struct { - Query string `json:"query" form:"query"` - LetterNumber string `json:"letter_number" form:"letter_number"` - Subject string `json:"subject" form:"subject"` - Status string `json:"status" form:"status"` - PriorityID *uuid.UUID `json:"priority_id" form:"priority_id"` - InstitutionID *uuid.UUID `json:"institution_id" form:"institution_id"` - CreatedBy *uuid.UUID `json:"created_by" form:"created_by"` - DateFrom *time.Time `json:"date_from" form:"date_from"` - DateTo *time.Time `json:"date_to" form:"date_to"` - Page int `json:"page" form:"page"` - Limit int `json:"limit" form:"limit"` - SortBy string `json:"sort_by" form:"sort_by"` - SortOrder string `json:"sort_order" form:"sort_order"` -} - -type SearchOutgoingLettersResponse struct { - Letters []OutgoingLetterResponse `json:"letters"` - TotalCount int64 `json:"total_count"` - Page int `json:"page"` - Limit int `json:"limit"` -} - -type CreateOutgoingLetterRecipient struct { - LetterID uuid.UUID `json:"letter_id"` - UserID *uuid.UUID `json:"user_id,omitempty"` - DepartmentID *uuid.UUID `json:"department_id,omitempty"` - IsPrimary bool `json:"is_primary"` - Status string `json:"status"` - Flag *string `json:"flag,omitempty"` - IsArchived bool `json:"is_archived"` -} - -type CreateOutgoingLetterAttachment struct { - FileURL string `json:"file_url" validate:"required"` - FileName string `json:"file_name" validate:"required"` - FileType string `json:"file_type" validate:"required"` -} - -type CreateOutgoingLetterRequest struct { - ReferenceNumber *string `json:"reference_number,omitempty"` - Subject string `json:"subject" validate:"required"` - Description *string `json:"description,omitempty"` - PriorityID *uuid.UUID `json:"priority_id,omitempty"` - ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"` - ReceiverName *string `json:"receiver_name,omitempty"` - IssueDate time.Time `json:"issue_date" validate:"required"` - Attachments []CreateOutgoingLetterAttachment `json:"attachments,omitempty"` - UserID uuid.UUID - ApprovalFlowID *uuid.UUID `json:"approval_flow_id,omitempty"` -} - -type OutgoingLetterRecipientResponse struct { - ID uuid.UUID `json:"id"` - LetterID uuid.UUID `json:"letter_id"` - UserID *uuid.UUID `json:"user_id,omitempty"` - DepartmentID *uuid.UUID `json:"department_id,omitempty"` - IsPrimary bool `json:"is_primary"` - Status string `json:"status"` - ReadAt *time.Time `json:"read_at,omitempty"` - Flag *string `json:"flag,omitempty"` - IsArchived bool `json:"is_archived"` - CreatedAt time.Time `json:"created_at"` - User *UserResponse `json:"user,omitempty"` - Department *DepartmentResponse `json:"department,omitempty"` -} - -type OutgoingLetterAttachmentResponse struct { - ID uuid.UUID `json:"id"` - FileURL string `json:"file_url"` - FileName string `json:"file_name"` - FileType string `json:"file_type"` - UploadedAt time.Time `json:"uploaded_at"` -} - -type OutgoingLetterApprovalResponse struct { - ID uuid.UUID `json:"id"` - StepOrder int `json:"step_order"` - ParallelGroup int `json:"parallel_group"` - IsRequired bool `json:"is_required"` - ApproverID *uuid.UUID `json:"approver_id,omitempty"` - Status string `json:"status"` - Remarks *string `json:"remarks,omitempty"` - ActedAt *time.Time `json:"acted_at,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -type OutgoingLetterResponse struct { - ID uuid.UUID `json:"id"` - LetterNumber string `json:"letter_number"` - ReferenceNumber *string `json:"reference_number,omitempty"` - Subject string `json:"subject"` - Description *string `json:"description,omitempty"` - PriorityID *uuid.UUID `json:"priority_id,omitempty"` - Priority *PriorityResponse `json:"priority,omitempty"` - ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"` - ReceiverInstitution *InstitutionResponse `json:"receiver_institution,omitempty"` - ReceiverName *string `json:"receiver_name,omitempty"` - IssueDate time.Time `json:"issue_date"` - Status string `json:"status"` - ApprovalFlowID *uuid.UUID `json:"approval_flow_id,omitempty"` - RevisionNumber int `json:"revision_number"` - CreatedBy uuid.UUID `json:"created_by"` - CreatedName string `json:"created_name"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - IsRead bool `json:"is_read"` - Recipients []OutgoingLetterRecipientResponse `json:"recipients,omitempty"` - Attachments []OutgoingLetterAttachmentResponse `json:"attachments,omitempty"` - FinalAttachments []OutgoingLetterAttachmentResponse `json:"final_attachments,omitempty"` - Approvals []OutgoingLetterApprovalResponse `json:"approvals,omitempty"` -} - -type UpdateOutgoingLetterRequest struct { - ReferenceNumber *string `json:"reference_number,omitempty"` - Subject *string `json:"subject,omitempty"` - Description *string `json:"description,omitempty"` - PriorityID *uuid.UUID `json:"priority_id,omitempty"` - ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"` - ReceiverName *string `json:"receiver_name,omitempty"` - IssueDate *time.Time `json:"issue_date,omitempty"` -} - -type ListOutgoingLettersRequest struct { - Page int `form:"page" json:"page"` - Limit int `form:"limit" json:"limit"` - Status string `form:"status" json:"status,omitempty"` - Query string `form:"q" json:"query,omitempty"` - CreatedBy *uuid.UUID `form:"created_by" json:"created_by,omitempty"` - DepartmentID *uuid.UUID `form:"department_id" json:"department_id,omitempty"` - ReceiverInstitutionID *uuid.UUID `form:"receiver_institution_id" json:"receiver_institution_id,omitempty"` - FromDate string `form:"from_date" json:"from_date,omitempty"` - ToDate string `form:"to_date" json:"to_date,omitempty"` - PriorityID *uuid.UUID `form:"priority_id" json:"priority_id,omitempty"` - PriorityIDs []uuid.UUID `json:"priority_ids,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"` - IsRead *bool `form:"is_read,omitempty"` -} - -type ListOutgoingLettersResponse struct { - Items []*OutgoingLetterResponse `json:"items"` - Total int64 `json:"total"` -} - -type ApproveLetterRequest struct { - Remarks *string `json:"remarks,omitempty"` -} - -type RejectLetterRequest struct { - Reason string `json:"reason" validate:"required"` -} - -type ReviseLetterRequest struct { - FileURL string `json:"file_url" validate:"required"` - FileName string `json:"file_name" validate:"required"` - FileType string `json:"file_type" validate:"required"` -} - -type AddRecipientsRequest struct { - Recipients []CreateOutgoingLetterRecipient `json:"recipients" validate:"required,dive"` -} - -type UpdateRecipientRequest struct { - UserID *uuid.UUID `json:"user_id,omitempty"` - DepartmentID *uuid.UUID `json:"department_id,omitempty"` - IsPrimary bool `json:"is_primary"` - Status *string `json:"status,omitempty"` - Flag *string `json:"flag,omitempty"` - IsArchived *bool `json:"is_archived,omitempty"` -} - -type AddAttachmentsRequest struct { - Attachments []CreateOutgoingLetterAttachment `json:"attachments" validate:"required,dive"` -} - -type CreateDiscussionAttachment struct { - FileURL string `json:"file_url" validate:"required"` - FileName string `json:"file_name" validate:"required"` - FileType string `json:"file_type" validate:"required"` -} - -type CreateDiscussionRequest struct { - ParentID *uuid.UUID `json:"parent_id,omitempty"` - Message string `json:"message" validate:"required"` - Mentions map[string]interface{} `json:"mentions,omitempty"` - Attachments []CreateDiscussionAttachment `json:"attachments,omitempty"` -} - -type UpdateDiscussionRequest struct { - Message string `json:"message" validate:"required"` - Mentions map[string]interface{} `json:"mentions,omitempty"` -} - -type DiscussionResponse struct { - ID uuid.UUID `json:"id"` - ParentID *uuid.UUID `json:"parent_id,omitempty"` - UserID uuid.UUID `json:"user_id"` - Message string `json:"message"` - Mentions map[string]interface{} `json:"mentions,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - EditedAt *time.Time `json:"edited_at,omitempty"` -} - -type ApprovalFlowRequest struct { - DepartmentID uuid.UUID `json:"department_id" validate:"required"` - Name string `json:"name" validate:"required"` - Description *string `json:"description,omitempty"` - IsActive bool `json:"is_active"` - Steps []ApprovalFlowStepRequest `json:"steps" validate:"required,dive"` -} - -type ApprovalFlowStepRequest struct { - StepOrder int `json:"step_order" validate:"required,min=1"` - ParallelGroup int `json:"parallel_group" validate:"min=1"` - ApproverRoleID *uuid.UUID `json:"approver_role_id,omitempty"` - ApproverUserID *uuid.UUID `json:"approver_user_id,omitempty"` - Required bool `json:"required"` -} - -type ApprovalFlowResponse struct { - ID uuid.UUID `json:"id"` - DepartmentID uuid.UUID `json:"department_id"` - Department *DepartmentResponse `json:"department,omitempty"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - IsActive bool `json:"is_active"` - Steps []ApprovalFlowStepResponse `json:"steps,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ApprovalFlowStepResponse struct { - ID uuid.UUID `json:"id"` - StepOrder int `json:"step_order"` - ParallelGroup int `json:"parallel_group"` - ApproverRoleID *uuid.UUID `json:"approver_role_id,omitempty"` - ApproverRole *RoleResponse `json:"approver_role,omitempty"` - ApproverUserID *uuid.UUID `json:"approver_user_id,omitempty"` - ApproverUser *UserResponse `json:"approver_user,omitempty"` - Required bool `json:"required"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListApprovalFlowsRequest struct { - Limit int `json:"limit"` - Page int `json:"page"` - Search *string `json:"search"` - DepartmentID *uuid.UUID `json:"department_id,omitempty"` - IsActive *bool `json:"is_active,omitempty"` -} - -type ListApprovalFlowsResponse struct { - Items []*ApprovalFlowResponse `json:"items"` - Total int64 `json:"total"` -} - -// Letter Approval Information for Approver -type LetterApprovalInfoResponse struct { - IsApproverOnActiveStep bool `json:"is_approver_on_active_step"` - DecisionStatus string `json:"decision_status"` - CanApprove bool `json:"can_approve"` - Actions []ApprovalAction `json:"actions"` - NotesVisibility string `json:"notes_visibility"` -} - -type ApprovalAction struct { - Type string `json:"type"` - Href string `json:"href"` - Method string `json:"method"` -} - -// OutgoingLetterApprovalDiscussionsResponse combines approvals and discussions for outgoing letters -type OutgoingLetterApprovalDiscussionsResponse struct { - Approvals []EnhancedOutgoingLetterApprovalResponse `json:"approvals"` - Discussions []OutgoingLetterDiscussionResponse `json:"discussions"` -} - -// EnhancedOutgoingLetterApprovalResponse includes approval details with related data -type EnhancedOutgoingLetterApprovalResponse struct { - ID uuid.UUID `json:"id"` - LetterID uuid.UUID `json:"letter_id"` - StepID uuid.UUID `json:"step_id"` - StepOrder int `json:"step_order"` - ParallelGroup int `json:"parallel_group"` - IsRequired bool `json:"is_required"` - ApproverID *uuid.UUID `json:"approver_id,omitempty"` - RevisionNumber int `json:"revision_number"` - Status string `json:"status"` - Remarks *string `json:"remarks,omitempty"` - ActedAt *time.Time `json:"acted_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - Step *ApprovalFlowStepResponse `json:"step,omitempty"` - Approver *UserResponse `json:"approver,omitempty"` -} - -type OutgoingLetterApprovalRevisionNumberResponse struct { - RevisionNumber int `json:"revision_number"` - Approvals []EnhancedOutgoingLetterApprovalResponse `json:"approvals"` -} - -// GetLetterApprovalsResponse represents the list of approvals for a letter -type GetLetterApprovalsResponse struct { - LetterID uuid.UUID `json:"letter_id"` - LetterNumber string `json:"letter_number"` - LetterStatus string `json:"letter_status"` - TotalSteps int `json:"total_steps"` - CurrentStep int `json:"current_step"` - CurrentRevisionNumber int `json:"current_revision_number"` - Approvals []OutgoingLetterApprovalRevisionNumberResponse `json:"approvals"` -} - -// OutgoingLetterDiscussionResponse represents a discussion on an outgoing letter -type OutgoingLetterDiscussionResponse struct { - ID uuid.UUID `json:"id"` - LetterID uuid.UUID `json:"letter_id"` - ParentID *uuid.UUID `json:"parent_id,omitempty"` - UserID uuid.UUID `json:"user_id"` - Message string `json:"message"` - Mentions map[string]interface{} `json:"mentions,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - EditedAt *time.Time `json:"edited_at,omitempty"` - User *UserResponse `json:"user,omitempty"` - MentionedUsers []UserResponse `json:"mentioned_users,omitempty"` - Attachments []OutgoingLetterDiscussionAttachmentResponse `json:"attachments,omitempty"` -} - -// OutgoingLetterDiscussionAttachmentResponse represents an attachment in a discussion -type OutgoingLetterDiscussionAttachmentResponse struct { - ID uuid.UUID `json:"id"` - DiscussionID uuid.UUID `json:"discussion_id"` - FileURL string `json:"file_url"` - FileName string `json:"file_name"` - FileType string `json:"file_type"` - UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"` - UploadedAt time.Time `json:"uploaded_at"` -} - -// TimelineEvent represents a single event in the approval timeline -type TimelineEvent struct { - ID string `json:"id"` - Type string `json:"type"` // "approval", "discussion", "submission", "rejection" - Timestamp time.Time `json:"timestamp"` - Actor *UserResponse `json:"actor,omitempty"` - Action string `json:"action"` - Description string `json:"description"` - Status string `json:"status,omitempty"` - StepOrder int `json:"step_order,omitempty"` - Message string `json:"message,omitempty"` - Data interface{} `json:"data,omitempty"` -} - -// ApprovalTimelineResponse represents the complete timeline for a letter -type ApprovalTimelineResponse struct { - LetterID uuid.UUID `json:"letter_id"` - LetterNumber string `json:"letter_number"` - Subject string `json:"subject"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - Timeline []TimelineEvent `json:"timeline"` - Summary TimelineSummary `json:"summary"` -} - -// TimelineSummary provides overview statistics for the timeline -type TimelineSummary struct { - TotalSteps int `json:"total_steps"` - CompletedSteps int `json:"completed_steps"` - PendingSteps int `json:"pending_steps"` - CurrentStep int `json:"current_step"` - TotalDuration string `json:"total_duration"` - AverageStepTime string `json:"average_step_time"` - Status string `json:"status"` -} diff --git a/internal/contract/notification_contract.go b/internal/contract/notification_contract.go deleted file mode 100644 index 12fb768..0000000 --- a/internal/contract/notification_contract.go +++ /dev/null @@ -1,107 +0,0 @@ -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/contract/onlyoffice_contract.go b/internal/contract/onlyoffice_contract.go deleted file mode 100644 index 3877609..0000000 --- a/internal/contract/onlyoffice_contract.go +++ /dev/null @@ -1,178 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -// OnlyOffice callback status codes -const ( - OnlyOfficeStatusEditing = 1 // Document is being edited - OnlyOfficeStatusReady = 2 // Document is ready for saving - OnlyOfficeStatusSaveError = 3 // Document saving error occurred - OnlyOfficeStatusClosed = 4 // Document is closed with no changes - OnlyOfficeStatusForceSave = 6 // Document is being edited, but saved forcefully - OnlyOfficeStatusForceSaveError = 7 // Error during force save -) - -// OnlyOfficeCallbackRequest represents the callback payload from OnlyOffice Document Server -type OnlyOfficeCallbackRequest struct { - Key string `json:"key"` // Document identifier - Status int `json:"status"` // Status code (1-7) - URL string `json:"url,omitempty"` // Document URL when status is 2 or 6 - ChangesURL string `json:"changesurl,omitempty"` // URL to document changes - History *OnlyOfficeHistory `json:"history,omitempty"` - Users []string `json:"users,omitempty"` // Users currently editing - Actions []OnlyOfficeAction `json:"actions,omitempty"` // User actions - LastSave string `json:"lastsave,omitempty"` // Last save time - NotModified bool `json:"notmodified,omitempty"` // Document not modified flag - ForceSaveType int `json:"forcesavetype,omitempty"` // Force save type - UserData string `json:"userdata,omitempty"` // Custom user data - Token string `json:"token,omitempty"` // JWT token from OnlyOffice -} - -// OnlyOfficeHistory represents document history information -type OnlyOfficeHistory struct { - ServerVersion string `json:"serverVersion"` - Changes []OnlyOfficeHistoryChange `json:"changes"` -} - -// OnlyOfficeHistoryChange represents a single change in history -type OnlyOfficeHistoryChange struct { - User OnlyOfficeUser `json:"user"` - Created string `json:"created"` -} - -// OnlyOfficeUser represents user information in OnlyOffice -type OnlyOfficeUser struct { - ID string `json:"id"` - Name string `json:"name"` -} - -// OnlyOfficeAction represents user actions -type OnlyOfficeAction struct { - Type int `json:"type"` // Action type (0: user connected, 1: user disconnected) - UserID string `json:"userid"` // User identifier - User OnlyOfficeUser `json:"user"` // User information -} - -// OnlyOfficeCallbackResponse is the required response format for OnlyOffice -type OnlyOfficeCallbackResponse struct { - Error int `json:"error"` // 0 = success, 1 = document key not found, 2 = callback URL error, 3 = internal server error -} - -// OnlyOfficeDocument represents the document section of OnlyOffice config -type OnlyOfficeDocument struct { - FileType string `json:"fileType"` // Document file extension - Key string `json:"key"` // Unique document identifier - Title string `json:"title"` // Document title - URL string `json:"url"` // Document URL - Permissions *OnlyOfficePermissions `json:"permissions,omitempty"` - Info *OnlyOfficeDocumentInfo `json:"info,omitempty"` -} - -// OnlyOfficeDocumentInfo represents additional document information -type OnlyOfficeDocumentInfo struct { - Owner string `json:"owner,omitempty"` - Uploaded string `json:"uploaded,omitempty"` -} - -// OnlyOfficeConfigRequest represents the proper OnlyOffice configuration format -type OnlyOfficeConfigRequest struct { - Document *OnlyOfficeDocument `json:"document"` - DocumentType string `json:"documentType"` // text, spreadsheet, presentation - EditorConfig *OnlyOfficeEditorConfig `json:"editorConfig"` - Type string `json:"type,omitempty"` // desktop, mobile, embedded - Token string `json:"token,omitempty"` // JWT token for security - Width string `json:"width,omitempty"` - Height string `json:"height,omitempty"` -} - -// OnlyOfficeUserConfig represents user configuration for OnlyOffice -type OnlyOfficeUserConfig struct { - ID string `json:"id"` - Name string `json:"name"` -} - -// OnlyOfficePermissions represents document permissions -type OnlyOfficePermissions struct { - Comment bool `json:"comment"` - Download bool `json:"download"` - Edit bool `json:"edit"` - FillForms bool `json:"fillForms"` - ModifyContentControl bool `json:"modifyContentControl"` - ModifyFilter bool `json:"modifyFilter"` - Print bool `json:"print"` - Review bool `json:"review"` -} - -// OnlyOfficeCustomization represents UI customization options -type OnlyOfficeCustomization struct { - Autosave bool `json:"autosave"` - Comments bool `json:"comments"` - CompactHeader bool `json:"compactHeader"` - CompactToolbar bool `json:"compactToolbar"` - ForceSave bool `json:"forcesave"` - ShowReviewChanges bool `json:"showReviewChanges"` - Zoom int `json:"zoom"` -} - -// OnlyOfficeEditorConfig represents editor configuration -type OnlyOfficeEditorConfig struct { - CallbackURL string `json:"callbackUrl"` - Lang string `json:"lang"` - Mode string `json:"mode"` // edit, view - User *OnlyOfficeUserConfig `json:"user,omitempty"` - Customization *OnlyOfficeCustomization `json:"customization,omitempty"` -} - -// Document session tracking for OnlyOffice -type DocumentSession struct { - ID uuid.UUID `json:"id"` - DocumentID uuid.UUID `json:"document_id"` - DocumentKey string `json:"document_key"` // OnlyOffice document key - UserID uuid.UUID `json:"user_id"` - Status int `json:"status"` // Current OnlyOffice status - IsLocked bool `json:"is_locked"` // Document lock status - LockedBy *uuid.UUID `json:"locked_by,omitempty"` - LockedAt *time.Time `json:"locked_at,omitempty"` - LastSavedAt *time.Time `json:"last_saved_at,omitempty"` - Version int `json:"version"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// DocumentVersion for tracking document versions -type DocumentVersion struct { - ID uuid.UUID `json:"id"` - DocumentID uuid.UUID `json:"document_id"` - Version int `json:"version"` - FileURL string `json:"file_url"` - FileSize int64 `json:"file_size"` - ChangesURL *string `json:"changes_url,omitempty"` - SavedBy uuid.UUID `json:"saved_by"` - SavedAt time.Time `json:"saved_at"` - IsActive bool `json:"is_active"` - Comments *string `json:"comments,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// GetEditorConfigRequest represents request to get OnlyOffice editor configuration -type GetEditorConfigRequest struct { - DocumentID uuid.UUID `json:"document_id"` - DocumentType string `json:"document_type"` // letter_attachment, outgoing_attachment, etc. - Mode string `json:"mode"` // edit, view -} - -// GetEditorConfigResponse represents OnlyOffice editor configuration response -type GetEditorConfigResponse struct { - DocumentServerURL string `json:"document_server_url"` - Config *OnlyOfficeConfigRequest `json:"config"` -} - -// OnlyOfficeConfigInfo represents the OnlyOffice configuration information -type OnlyOfficeConfigInfo struct { - URL string `json:"url"` - Token string `json:"token"` -} diff --git a/internal/contract/rbac_contract.go b/internal/contract/rbac_contract.go deleted file mode 100644 index 8ac60c1..0000000 --- a/internal/contract/rbac_contract.go +++ /dev/null @@ -1,107 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -type PermissionResponse struct { - ID uuid.UUID `json:"id"` - Code string `json:"code"` - Action string `json:"action,omitempty"` - Description *string `json:"description,omitempty"` -} - -type CreatePermissionRequest struct { - Code string `json:"code"` // unique - Description *string `json:"description,omitempty"` -} - -type UpdatePermissionRequest struct { - Code *string `json:"code,omitempty"` - Description *string `json:"description,omitempty"` -} - -type ListPermissionsResponse struct { - Permissions []PermissionResponse `json:"permissions"` -} - -type RoleWithPermissionsResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - Description *string `json:"description,omitempty"` - Permissions []PermissionResponse `json:"permissions"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type CreateRoleRequest struct { - Name string `json:"name"` - Code string `json:"code"` - Description *string `json:"description,omitempty"` - PermissionCodes []string `json:"permission_codes,omitempty"` -} - -type UpdateRoleRequest struct { - Name *string `json:"name,omitempty"` - Code *string `json:"code,omitempty"` - Description *string `json:"description,omitempty"` - PermissionCodes *[]string `json:"permission_codes,omitempty"` -} - -type ListRolesResponse struct { - Roles []RoleWithPermissionsResponse `json:"roles"` -} - -// Module contracts -type ModuleResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Code string `json:"code"` -} - -type ModuleWithPermissionsResponse struct { - Module ModuleResponse `json:"module"` - Permissions []PermissionResponse `json:"permissions"` -} - -type PermissionsGroupedResponse struct { - Data []ModuleWithPermissionsResponse `json:"data"` -} - -// New Role contracts for the required API -type ModulePermissionInput struct { - Module string `json:"module" binding:"required"` - Actions []string `json:"actions" binding:"required"` -} - -type CreateOrUpdateRoleRequest struct { - Name string `json:"name" binding:"required"` - Code string `json:"code" binding:"required"` - Description string `json:"description,omitempty"` - Permissions []ModulePermissionInput `json:"permissions" binding:"required"` -} - -type RoleDetailResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - Description string `json:"description,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Permissions []RolePermissionModuleResponse `json:"permissions"` -} - -type RolePermissionModuleResponse struct { - Module ModuleResponse `json:"module"` - Actions []PermissionActionResponse `json:"actions"` -} - -type PermissionActionResponse struct { - ID uuid.UUID `json:"id"` - Action string `json:"action"` - Code string `json:"code"` - Description string `json:"description,omitempty"` -} diff --git a/internal/contract/repository_attachment_contract.go b/internal/contract/repository_attachment_contract.go deleted file mode 100644 index df88613..0000000 --- a/internal/contract/repository_attachment_contract.go +++ /dev/null @@ -1,35 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateRepositoryAttachmentRequest struct { - FileURL string `json:"file_url" validate:"required"` - FileName string `json:"file_name" validate:"required"` - FileType string `json:"file_type" validate:"required"` - Category string `json:"category" validate:"required"` -} - -type RepositoryAttachmentsResponse struct { - ID uuid.UUID `json:"id"` - FileURL string `json:"file_url"` - FileName string `json:"file_name"` - FileType string `json:"file_type"` - Category string `json:"category"` - UploadBy uuid.UUID `json:"upload_by"` - UploadAt time.Time `json:"upload_at"` -} - -type ListRepositoryAttachmentsResponse struct { - Attachments []RepositoryAttachmentsResponse `json:"attachments"` - Pagination PaginationResponse `json:"pagination"` -} - -type ListRepositoryAttachmentsRequest struct { - Page int `json:"page"` - Limit int `json:"limit"` - Search *string `json:"search,omitempty"` -} diff --git a/internal/contract/user_contract.go b/internal/contract/user_contract.go index c73c788..7b4cd7e 100644 --- a/internal/contract/user_contract.go +++ b/internal/contract/user_contract.go @@ -7,20 +7,15 @@ import ( ) type CreateUserRequest struct { - Name string `json:"name" validate:"required,min=1,max=255"` - Email string `json:"email" validate:"required,email"` - Password string `json:"password" validate:"required,min=6"` - RoleID *uuid.UUID `json:"role_id,omitempty" validate:"required"` - DepartmentIDs []uuid.UUID `json:"department_ids,omitempty"` + Name string `json:"name" validate:"required,min=1,max=255"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=6"` } type UpdateUserRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` - Email *string `json:"email,omitempty" validate:"omitempty,email"` - Role *uuid.UUID `json:"role,omitempty"` - IsActive *bool `json:"is_active,omitempty"` - Permissions *map[string]interface{} `json:"permissions,omitempty"` - DepartmentIDs *[]uuid.UUID `json:"department_ids,omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` + Email *string `json:"email,omitempty" validate:"omitempty,email"` + IsActive *bool `json:"is_active,omitempty"` } type ChangePasswordRequest struct { @@ -28,169 +23,38 @@ type ChangePasswordRequest struct { NewPassword string `json:"new_password" validate:"required,min=6"` } -type ChangeUserPasswordRequest struct { - NewPassword string `json:"new_password" validate:"required,min=6"` -} - -type UpdateUserOutletRequest struct { - OutletID uuid.UUID `json:"outlet_id" validate:"required"` -} - type LoginRequest struct { Email string `json:"email" validate:"required,email"` Password string `json:"password" validate:"required"` } type LoginResponse struct { - Token string `json:"token"` - ExpiresAt time.Time `json:"expires_at"` - User UserResponse `json:"user"` - Roles []RoleResponse `json:"roles"` - Permissions []string `json:"permissions"` - Departments []DepartmentResponse `json:"departments"` + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + User *UserResponse `json:"user"` +} + +type RefreshTokenRequest struct { + RefreshToken string `json:"refresh_token" validate:"required"` } type UserResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Roles []RoleResponse `json:"roles,omitempty"` - DepartmentResponse []DepartmentResponse `json:"department_response"` - Profile *UserProfileResponse `json:"profile,omitempty"` + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ListUsersRequest struct { Page int `json:"page" validate:"min=1"` Limit int `json:"limit" validate:"min=1,max=100"` - Role *string `json:"role,omitempty"` IsActive *bool `json:"is_active,omitempty"` Search *string `json:"search,omitempty"` - RoleCode *string `json:"role_code,omitempty"` } -type ListUsersResponse struct { +type PaginatedUserResponse struct { Users []UserResponse `json:"users"` Pagination PaginationResponse `json:"pagination"` } - -type RoleResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Code string `json:"code"` -} - -type DepartmentResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - Path string `json:"path"` -} - -type ListDepartmentsRequest struct { - Search string `json:"search,omitempty" form:"search"` - Page int `json:"page,omitempty" form:"page"` - Limit int `json:"limit,omitempty" form:"limit"` -} - -type ListDepartmentsResponse struct { - Departments []DepartmentResponse `json:"departments"` - Total int64 `json:"total"` - Page int `json:"page"` - Limit int `json:"limit"` -} - -type CreateDepartmentRequest struct { - Name string `json:"name" validate:"required,min=1,max=255"` - Code string `json:"code" validate:"required,min=1,max=50"` - ParentID *uuid.UUID `json:"parent_id,omitempty"` -} - -type UpdateDepartmentRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` - Code *string `json:"code,omitempty" validate:"omitempty,min=1,max=50"` - ParentID *uuid.UUID `json:"parent_id,omitempty"` -} - -type GetDepartmentResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - Path string `json:"path"` - ParentID *uuid.UUID `json:"parent_id,omitempty"` - ParentName *string `json:"parent_name,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type DepartmentNode struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - Path string `json:"path"` - Level int `json:"level"` - Children []*DepartmentNode `json:"children,omitempty"` -} - -type OrganizationalChartResponse struct { - Chart []*DepartmentNode `json:"chart"` - TotalNodes int `json:"total_nodes"` -} - -type UserProfileResponse struct { - UserID uuid.UUID `json:"user_id"` - FullName string `json:"full_name"` - DisplayName *string `json:"display_name,omitempty"` - Phone *string `json:"phone,omitempty"` - AvatarURL *string `json:"avatar_url,omitempty"` - JobTitle *string `json:"job_title,omitempty"` - EmployeeNo *string `json:"employee_no,omitempty"` - Bio *string `json:"bio,omitempty"` - Timezone string `json:"timezone"` - Locale string `json:"locale"` - Preferences map[string]interface{} `json:"preferences"` - NotificationPrefs map[string]interface{} `json:"notification_prefs"` - LastSeenAt *time.Time `json:"last_seen_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Roles []RoleResponse `json:"roles"` -} - -type UpdateUserProfileRequest struct { - FullName *string `json:"full_name,omitempty"` - DisplayName *string `json:"display_name,omitempty"` - Phone *string `json:"phone,omitempty"` - AvatarURL *string `json:"avatar_url,omitempty"` - JobTitle *string `json:"job_title,omitempty"` - EmployeeNo *string `json:"employee_no,omitempty"` - Bio *string `json:"bio,omitempty"` - Timezone *string `json:"timezone,omitempty"` - Locale *string `json:"locale,omitempty"` - Preferences *map[string]interface{} `json:"preferences,omitempty"` - NotificationPrefs *map[string]interface{} `json:"notification_prefs,omitempty"` -} - -type TitleResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Code *string `json:"code,omitempty"` - Description *string `json:"description,omitempty"` -} - -type ListTitlesResponse struct { - Titles []TitleResponse `json:"titles"` -} - -// MentionUsersRequest represents the request for getting users for mention purposes -type MentionUsersRequest struct { - Search *string `json:"search,omitempty" form:"search"` // Optional search term for username - Limit *int `json:"limit,omitempty" form:"limit"` // Optional limit, defaults to 50, max 100 -} - -// MentionUsersResponse represents the response for getting users for mention purposes -type MentionUsersResponse struct { - Users []UserResponse `json:"users"` - Count int `json:"count"` -} diff --git a/internal/db/database.go b/internal/db/database.go index 773113c..5dde85b 100644 --- a/internal/db/database.go +++ b/internal/db/database.go @@ -1,7 +1,7 @@ package db import ( - "eslogad-be/config" + "go-backend-template/config" "fmt" "io/fs" "os" diff --git a/internal/entities/approval_flow.go b/internal/entities/approval_flow.go deleted file mode 100644 index d1fd7d5..0000000 --- a/internal/entities/approval_flow.go +++ /dev/null @@ -1,71 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type ApprovalFlow struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - DepartmentID uuid.UUID `gorm:"type:uuid;not null" json:"department_id"` - Name string `gorm:"not null" json:"name"` - Description *string `json:"description,omitempty"` - IsActive bool `gorm:"default:true" json:"is_active"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - - // Relations - Department *Department `gorm:"foreignKey:DepartmentID" json:"department,omitempty"` - Steps []ApprovalFlowStep `gorm:"foreignKey:FlowID" json:"steps,omitempty"` -} - -func (ApprovalFlow) TableName() string { return "approval_flows" } - -type ApprovalFlowStep struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - FlowID uuid.UUID `gorm:"type:uuid;not null" json:"flow_id"` - StepOrder int `gorm:"not null" json:"step_order"` - ParallelGroup int `gorm:"default:1" json:"parallel_group"` - ApproverRoleID *uuid.UUID `json:"approver_role_id,omitempty"` - ApproverUserID *uuid.UUID `json:"approver_user_id,omitempty"` - Required bool `gorm:"default:true" json:"required"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - ApprovalFlow *ApprovalFlow `gorm:"foreignKey:FlowID" json:"approval_flow,omitempty"` - ApproverRole *Role `gorm:"foreignKey:ApproverRoleID" json:"approver_role,omitempty"` - ApproverUser *User `gorm:"foreignKey:ApproverUserID" json:"approver_user,omitempty"` -} - -func (ApprovalFlowStep) TableName() string { return "approval_flow_steps" } - -type ApprovalStatus string - -const ( - ApprovalStatusNotStarted ApprovalStatus = "not_started" - ApprovalStatusPending ApprovalStatus = "pending" - ApprovalStatusApproved ApprovalStatus = "approved" - ApprovalStatusRejected ApprovalStatus = "rejected" -) - -type LetterOutgoingApproval struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - StepID uuid.UUID `gorm:"type:uuid;not null" json:"step_id"` - RevisionNumber int `gorm:"not null;default:0" json:"revision_number"` - StepOrder int `gorm:"not null" json:"step_order"` - ParallelGroup int `gorm:"default:1" json:"parallel_group"` - IsRequired bool `gorm:"default:true" json:"is_required"` - ApproverID *uuid.UUID `json:"approver_id,omitempty"` - Status ApprovalStatus `gorm:"not null;default:'pending'" json:"status"` - Remarks *string `json:"remarks,omitempty"` - ActedAt *time.Time `json:"acted_at,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - - // Relations - Letter *LetterOutgoing `gorm:"foreignKey:LetterID" json:"letter,omitempty"` - Step *ApprovalFlowStep `gorm:"foreignKey:StepID" json:"step,omitempty"` - Approver *User `gorm:"foreignKey:ApproverID" json:"approver,omitempty"` -} - -func (LetterOutgoingApproval) TableName() string { return "letter_outgoing_approvals" } diff --git a/internal/entities/department.go b/internal/entities/department.go deleted file mode 100644 index 35a65f1..0000000 --- a/internal/entities/department.go +++ /dev/null @@ -1,23 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type Department struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Name string `gorm:"not null" json:"name"` - Code string `json:"code,omitempty"` - Path string `gorm:"not null" json:"path"` - ParentDepartmentID *uuid.UUID `gorm:"type:uuid" json:"parent_department_id,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - - // Relations - ParentDepartment *Department `gorm:"foreignKey:ParentDepartmentID" json:"parent_department,omitempty"` - ChildDepartments []Department `gorm:"foreignKey:ParentDepartmentID" json:"child_departments,omitempty"` -} - -func (Department) TableName() string { return "departments" } diff --git a/internal/entities/department_recipients_setting.go b/internal/entities/department_recipients_setting.go deleted file mode 100644 index 9ab602d..0000000 --- a/internal/entities/department_recipients_setting.go +++ /dev/null @@ -1,8 +0,0 @@ -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/disposition_action.go b/internal/entities/disposition_action.go deleted file mode 100644 index 30844a6..0000000 --- a/internal/entities/disposition_action.go +++ /dev/null @@ -1,22 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type DispositionAction struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Code string `gorm:"uniqueIndex;not null" json:"code"` - Label string `gorm:"not null" json:"label"` - Description *string `json:"description,omitempty"` - RequiresNote bool `gorm:"not null;default:false" json:"requires_note"` - GroupName *string `json:"group_name,omitempty"` - SortOrder *int `json:"sort_order,omitempty"` - IsActive bool `gorm:"not null;default:true" json:"is_active"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` -} - -func (DispositionAction) TableName() string { return "disposition_actions" } diff --git a/internal/entities/disposition_route.go b/internal/entities/disposition_route.go deleted file mode 100644 index 715477f..0000000 --- a/internal/entities/disposition_route.go +++ /dev/null @@ -1,23 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type DispositionRoute struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - FromDepartmentID uuid.UUID `gorm:"type:uuid;not null" json:"from_department_id"` - ToDepartmentID uuid.UUID `gorm:"type:uuid;not null" json:"to_department_id"` - IsActive bool `gorm:"not null;default:true" json:"is_active"` - AllowedActions JSONB `gorm:"type:jsonb" json:"allowed_actions,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - - // Relationships - FromDepartment Department `gorm:"foreignKey:FromDepartmentID;references:ID" json:"from_department,omitempty"` - ToDepartment Department `gorm:"foreignKey:ToDepartmentID;references:ID" json:"to_department,omitempty"` -} - -func (DispositionRoute) TableName() string { return "disposition_routes" } diff --git a/internal/entities/document_session.go b/internal/entities/document_session.go deleted file mode 100644 index 9691312..0000000 --- a/internal/entities/document_session.go +++ /dev/null @@ -1,117 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -// DocumentSession represents an OnlyOffice document editing session -type DocumentSession struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - DocumentID uuid.UUID `gorm:"type:uuid;not null;index" json:"document_id"` - DocumentKey string `gorm:"uniqueIndex;not null" json:"document_key"` // OnlyOffice document key - UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"` - Status int `gorm:"not null;default:0" json:"status"` // OnlyOffice status codes - IsLocked bool `gorm:"default:false" json:"is_locked"` - LockedBy *uuid.UUID `gorm:"type:uuid" json:"locked_by,omitempty"` - LockedAt *time.Time `json:"locked_at,omitempty"` - LastSavedAt *time.Time `json:"last_saved_at,omitempty"` - Version int `gorm:"not null;default:1" json:"version"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - - // Relations - User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` -} - -func (DocumentSession) TableName() string { - return "document_sessions" -} - -func (ds *DocumentSession) BeforeCreate(tx *gorm.DB) error { - if ds.ID == uuid.Nil { - ds.ID = uuid.New() - } - return nil -} - -// DocumentVersion represents a version of a document -type DocumentVersion struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - DocumentID uuid.UUID `gorm:"type:uuid;not null;index" json:"document_id"` - Version int `gorm:"not null" json:"version"` - FileURL string `gorm:"not null" json:"file_url"` - FileSize int64 `gorm:"not null" json:"file_size"` - ChangesURL *string `json:"changes_url,omitempty"` - SavedBy uuid.UUID `gorm:"type:uuid;not null" json:"saved_by"` - SavedAt time.Time `gorm:"not null" json:"saved_at"` - IsActive bool `gorm:"default:false;index" json:"is_active"` - Comments *string `json:"comments,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - - // Relations - User *User `gorm:"foreignKey:SavedBy" json:"user,omitempty"` -} - -func (DocumentVersion) TableName() string { - return "document_versions" -} - -func (dv *DocumentVersion) BeforeCreate(tx *gorm.DB) error { - if dv.ID == uuid.Nil { - dv.ID = uuid.New() - } - return nil -} - -// DocumentError represents errors during document operations -type DocumentError struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - DocumentID uuid.UUID `gorm:"type:uuid;not null;index" json:"document_id"` - SessionID *uuid.UUID `gorm:"type:uuid" json:"session_id,omitempty"` - ErrorType string `gorm:"not null" json:"error_type"` - ErrorMsg string `gorm:"not null" json:"error_msg"` - Details map[string]interface{} `gorm:"type:jsonb" json:"details,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - - // Relations - Session *DocumentSession `gorm:"foreignKey:SessionID" json:"session,omitempty"` -} - -func (DocumentError) TableName() string { - return "document_errors" -} - -func (de *DocumentError) BeforeCreate(tx *gorm.DB) error { - if de.ID == uuid.Nil { - de.ID = uuid.New() - } - return nil -} - -// DocumentMetadata stores document-specific metadata -type DocumentMetadata struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - DocumentID uuid.UUID `gorm:"type:uuid;uniqueIndex;not null" json:"document_id"` - DocumentType string `gorm:"not null" json:"document_type"` // letter_attachment, outgoing_attachment, etc. - ReferenceID uuid.UUID `gorm:"type:uuid;not null" json:"reference_id"` // ID of the parent entity (letter, outgoing letter, etc.) - FileName string `gorm:"not null" json:"file_name"` - FileType string `gorm:"not null" json:"file_type"` - FileSize int64 `gorm:"not null" json:"file_size"` - MimeType string `json:"mime_type,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` -} - -func (DocumentMetadata) TableName() string { - return "document_metadata" -} - -func (dm *DocumentMetadata) BeforeCreate(tx *gorm.DB) error { - if dm.ID == uuid.Nil { - dm.ID = uuid.New() - } - return nil -} \ No newline at end of file diff --git a/internal/entities/institution.go b/internal/entities/institution.go deleted file mode 100644 index ee4bf85..0000000 --- a/internal/entities/institution.go +++ /dev/null @@ -1,30 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type InstitutionType string - -const ( - InstGovernment InstitutionType = "government" - InstPrivate InstitutionType = "private" - InstNGO InstitutionType = "ngo" - InstIndividual InstitutionType = "individual" -) - -type Institution struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Name string `gorm:"not null;size:255" json:"name"` - Type InstitutionType `gorm:"not null;size:32" json:"type"` - Address *string `json:"address,omitempty"` - ContactPerson *string `gorm:"size:255" json:"contact_person,omitempty"` - Phone *string `gorm:"size:50" json:"phone,omitempty"` - Email *string `gorm:"size:255" json:"email,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` -} - -func (Institution) TableName() string { return "institutions" } diff --git a/internal/entities/label.go b/internal/entities/label.go deleted file mode 100644 index fce6195..0000000 --- a/internal/entities/label.go +++ /dev/null @@ -1,17 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type Label struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Name string `gorm:"not null;size:255" json:"name"` - Color *string `gorm:"size:16" json:"color,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` -} - -func (Label) TableName() string { return "labels" } diff --git a/internal/entities/letter_discussion.go b/internal/entities/letter_discussion.go deleted file mode 100644 index e6cf36a..0000000 --- a/internal/entities/letter_discussion.go +++ /dev/null @@ -1,24 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type LetterDiscussion struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - ParentID *uuid.UUID `json:"parent_id,omitempty"` - UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"` - Message string `gorm:"not null" json:"message"` - Mentions JSONB `gorm:"type:jsonb" json:"mentions,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - EditedAt *time.Time `json:"edited_at,omitempty"` - - // Relationships - User *User `gorm:"foreignKey:UserID;references:ID" json:"user,omitempty"` -} - -func (LetterDiscussion) TableName() string { return "letter_incoming_discussions" } diff --git a/internal/entities/letter_disposition.go b/internal/entities/letter_disposition.go deleted file mode 100644 index 916da88..0000000 --- a/internal/entities/letter_disposition.go +++ /dev/null @@ -1,82 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type LetterIncomingDisposition struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - DepartmentID *uuid.UUID `json:"department_id,omitempty"` - Notes *string `json:"notes,omitempty"` - ReadAt *time.Time `json:"read_at,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - CreatedBy uuid.UUID `gorm:"not null" json:"created_by"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - Department Department `gorm:"foreignKey:DepartmentID;references:ID" json:"department,omitempty"` - Departments []LetterIncomingDispositionDepartment `gorm:"foreignKey:LetterIncomingDispositionID;references:ID" json:"departments,omitempty"` - ActionSelections []LetterDispositionActionSelection `gorm:"foreignKey:DispositionID;references:ID" json:"action_selections,omitempty"` - DispositionNotes []DispositionNote `gorm:"foreignKey:DispositionID;references:ID" json:"disposition_notes,omitempty"` -} - -func (LetterIncomingDisposition) TableName() string { return "letter_incoming_dispositions" } - -type 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"` - 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"` - 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 { - return "letter_incoming_dispositions_department" -} - -type DispositionNote struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - DispositionID uuid.UUID `gorm:"type:uuid;not null" json:"disposition_id"` - UserID *uuid.UUID `json:"user_id,omitempty"` - Note string `gorm:"not null" json:"note"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - - // Relationships - User *User `gorm:"foreignKey:UserID;references:ID" json:"user,omitempty"` -} - -func (DispositionNote) TableName() string { return "disposition_notes" } - -type LetterDispositionActionSelection struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - DispositionID uuid.UUID `gorm:"type:uuid;not null" json:"disposition_id"` - ActionID uuid.UUID `gorm:"type:uuid;not null" json:"action_id"` - Note *string `json:"note,omitempty"` - CreatedBy uuid.UUID `gorm:"not null" json:"created_by"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - - // Relationships - Action *DispositionAction `gorm:"foreignKey:ActionID;references:ID" json:"action,omitempty"` -} - -func (LetterDispositionActionSelection) TableName() string { return "letter_disposition_actions" } diff --git a/internal/entities/letter_incoming.go b/internal/entities/letter_incoming.go deleted file mode 100644 index 893832e..0000000 --- a/internal/entities/letter_incoming.go +++ /dev/null @@ -1,57 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type LetterIncomingStatus string - -const ( - LetterIncomingStatusNew LetterIncomingStatus = "new" - LetterIncomingStatusInProgress LetterIncomingStatus = "in_progress" - LetterIncomingStatusCompleted LetterIncomingStatus = "completed" -) - -type LetterIncomingType string - -const ( - LetterIncomingTypeUtama LetterIncomingType = "UTAMA" - LetterIncomingTypeTembusan LetterIncomingType = "TEMBUSAN" -) - -type LetterIncoming struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterNumber string `gorm:"uniqueIndex;not null" json:"letter_number"` - ReferenceNumber *string `json:"reference_number,omitempty"` - Subject string `gorm:"not null" json:"subject"` - Description *string `json:"description,omitempty"` - PriorityID *uuid.UUID `json:"priority_id,omitempty"` - SenderInstitutionID *uuid.UUID `json:"sender_institution_id,omitempty"` - SenderName *string `json:"sender_name,omitempty"` - Addressee *string `json:"addressee,omitempty"` - ReceivedDate time.Time `json:"received_date"` - DueDate *time.Time `json:"due_date,omitempty"` - Type LetterIncomingType `gorm:"not null;default:'UTAMA'" json:"type"` - Status LetterIncomingStatus `gorm:"not null;default:'new'" json:"status"` - IsArchived bool `gorm:"not null;default:false" json:"is_archived"` - ArchivedAt *time.Time `json:"archived_at,omitempty"` - CreatedBy uuid.UUID `gorm:"not null" json:"created_by"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` -} - -func (LetterIncoming) TableName() string { return "letters_incoming" } - -type LetterIncomingAttachment struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - FileURL string `gorm:"not null" json:"file_url"` - FileName string `gorm:"not null" json:"file_name"` - FileType string `gorm:"not null" json:"file_type"` - UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"` - UploadedAt time.Time `gorm:"autoCreateTime" json:"uploaded_at"` -} - -func (LetterIncomingAttachment) TableName() string { return "letter_incoming_attachments" } diff --git a/internal/entities/letter_incoming_activity_log.go b/internal/entities/letter_incoming_activity_log.go deleted file mode 100644 index 005a658..0000000 --- a/internal/entities/letter_incoming_activity_log.go +++ /dev/null @@ -1,23 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type LetterIncomingActivityLog struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - ActionType string `gorm:"not null" json:"action_type"` - ActorUserID *uuid.UUID `json:"actor_user_id,omitempty"` - ActorDepartmentID *uuid.UUID `json:"actor_department_id,omitempty"` - TargetType *string `json:"target_type,omitempty"` - TargetID *uuid.UUID `json:"target_id,omitempty"` - FromStatus *string `json:"from_status,omitempty"` - ToStatus *string `json:"to_status,omitempty"` - Context JSONB `gorm:"type:jsonb" json:"context,omitempty"` - OccurredAt time.Time `gorm:"autoCreateTime" json:"occurred_at"` -} - -func (LetterIncomingActivityLog) TableName() string { return "letter_incoming_activity_logs" } diff --git a/internal/entities/letter_incoming_recipient.go b/internal/entities/letter_incoming_recipient.go deleted file mode 100644 index bb16799..0000000 --- a/internal/entities/letter_incoming_recipient.go +++ /dev/null @@ -1,28 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type LetterIncomingRecipientStatus string - -const ( - RecipientStatusNew LetterIncomingRecipientStatus = "new" - RecipientStatusRead LetterIncomingRecipientStatus = "read" - RecipientStatusCompleted LetterIncomingRecipientStatus = "completed" -) - -type LetterIncomingRecipient struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - RecipientUserID *uuid.UUID `json:"recipient_user_id,omitempty"` - RecipientDepartmentID *uuid.UUID `json:"recipient_department_id,omitempty"` - Status LetterIncomingRecipientStatus `gorm:"not null;default:'new'" json:"status"` - ReadAt *time.Time `json:"read_at,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` -} - -func (LetterIncomingRecipient) TableName() string { return "letter_incoming_recipients" } diff --git a/internal/entities/letter_outgoing.go b/internal/entities/letter_outgoing.go deleted file mode 100644 index aff49bb..0000000 --- a/internal/entities/letter_outgoing.go +++ /dev/null @@ -1,126 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type LetterOutgoingStatus string - -const ( - LetterOutgoingStatusDraft LetterOutgoingStatus = "draft" - LetterOutgoingStatusPendingApproval LetterOutgoingStatus = "pending_approval" - LetterOutgoingStatusApproved LetterOutgoingStatus = "approved" - LetterOutgoingStatusSent LetterOutgoingStatus = "sent" - LetterOutgoingStatusRejected LetterOutgoingStatus = "rejected" -) - -type LetterOutgoing struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterNumber string `gorm:"uniqueIndex;not null" json:"letter_number"` - ReferenceNumber *string `json:"reference_number,omitempty"` - Subject string `gorm:"not null" json:"subject"` - Description *string `json:"description,omitempty"` - PriorityID *uuid.UUID `json:"priority_id,omitempty"` - ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"` - ReceiverName *string `json:"receiver_name,omitempty"` - IssueDate time.Time `json:"issue_date"` - Status LetterOutgoingStatus `gorm:"not null;default:'draft'" json:"status"` - ApprovalFlowID *uuid.UUID `json:"approval_flow_id,omitempty"` - RevisionNumber int `gorm:"not null;default:0" json:"revision_number"` - CreatedBy uuid.UUID `gorm:"not null" json:"created_by"` - IsArchived bool `gorm:"not null;default:false" json:"is_archived"` - ArchivedAt *time.Time `json:"archived_at,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - DeletedAt *time.Time `gorm:"index" json:"deleted_at,omitempty"` - - // Relations - Priority *Priority `gorm:"foreignKey:PriorityID" json:"priority,omitempty"` - ReceiverInstitution *Institution `gorm:"foreignKey:ReceiverInstitutionID" json:"receiver_institution,omitempty"` - Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"` - ApprovalFlow *ApprovalFlow `gorm:"foreignKey:ApprovalFlowID" json:"approval_flow,omitempty"` - Recipients []LetterOutgoingRecipient `gorm:"foreignKey:LetterID" json:"recipients,omitempty"` - Attachments []LetterOutgoingAttachment `gorm:"foreignKey:LetterID" json:"attachments,omitempty"` - Approvals []LetterOutgoingApproval `gorm:"foreignKey:LetterID" json:"approvals,omitempty"` - Discussions []LetterOutgoingDiscussion `gorm:"foreignKey:LetterID" json:"discussions,omitempty"` - FinalAttachments []LetterOutgoingFinalAttachment `gorm:"foreignKey:LetterID" json:"final_attachments,omitempty"` -} - -func (LetterOutgoing) TableName() string { return "letters_outgoing" } - -type LetterOutgoingRecipient struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - UserID *uuid.UUID `gorm:"type:uuid" json:"user_id,omitempty"` - DepartmentID *uuid.UUID `gorm:"type:uuid" json:"department_id,omitempty"` - IsPrimary bool `gorm:"default:false" json:"is_primary"` - Status string `gorm:"default:'pending'" json:"status"` - ReadAt *time.Time `json:"read_at,omitempty"` - Flag *string `json:"flag,omitempty"` - IsArchived bool `gorm:"default:false" json:"is_archived"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` - Department *Department `gorm:"foreignKey:DepartmentID" json:"department,omitempty"` -} - -func (LetterOutgoingRecipient) TableName() string { return "letter_outgoing_recipients" } - -type LetterOutgoingAttachment struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - RevisionNumber int `gorm:"not null;default:0" json:"revision_number"` - FileURL string `gorm:"not null" json:"file_url"` - FileName string `gorm:"not null" json:"file_name"` - FileType string `gorm:"not null" json:"file_type"` - UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"` - UploadedAt time.Time `gorm:"autoCreateTime" json:"uploaded_at"` -} - -func (LetterOutgoingAttachment) TableName() string { return "letter_outgoing_attachments" } - -type LetterOutgoingFinalAttachment struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - FileURL string `gorm:"not null" json:"file_url"` - FileName string `gorm:"not null" json:"file_name"` - FileType string `gorm:"not null" json:"file_type"` - UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"` - UploadedAt time.Time `gorm:"autoCreateTime" json:"uploaded_at"` -} - -func (LetterOutgoingFinalAttachment) TableName() string { return "letter_outgoing_final_attachments" } - -type LetterOutgoingDiscussion struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - ParentID *uuid.UUID `json:"parent_id,omitempty"` - UserID uuid.UUID `gorm:"not null" json:"user_id"` - Message string `gorm:"not null" json:"message"` - Mentions JSONB `gorm:"type:jsonb" json:"mentions,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - EditedAt *time.Time `json:"edited_at,omitempty"` - - // Relations - User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` - Attachments []LetterOutgoingDiscussionAttachment `gorm:"foreignKey:DiscussionID" json:"attachments,omitempty"` - Replies []LetterOutgoingDiscussion `gorm:"foreignKey:ParentID" json:"replies,omitempty"` -} - -func (LetterOutgoingDiscussion) TableName() string { return "letter_outgoing_discussions" } - -type LetterOutgoingDiscussionAttachment struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - DiscussionID uuid.UUID `gorm:"type:uuid;not null" json:"discussion_id"` - FileURL string `gorm:"not null" json:"file_url"` - FileName string `gorm:"not null" json:"file_name"` - FileType string `gorm:"not null" json:"file_type"` - UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"` - UploadedAt time.Time `gorm:"autoCreateTime" json:"uploaded_at"` -} - -func (LetterOutgoingDiscussionAttachment) TableName() string { - return "letter_outgoing_discussion_attachments" -} diff --git a/internal/entities/letter_outgoing_activity_log.go b/internal/entities/letter_outgoing_activity_log.go deleted file mode 100644 index d8ba089..0000000 --- a/internal/entities/letter_outgoing_activity_log.go +++ /dev/null @@ -1,47 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type LetterOutgoingActivityLog struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - ActionType string `gorm:"not null" json:"action_type"` - ActorUserID *uuid.UUID `json:"actor_user_id,omitempty"` - ActorDepartmentID *uuid.UUID `json:"actor_department_id,omitempty"` - TargetType *string `json:"target_type,omitempty"` - TargetID *uuid.UUID `json:"target_id,omitempty"` - FromStatus *string `json:"from_status,omitempty"` - ToStatus *string `json:"to_status,omitempty"` - Context JSONB `gorm:"type:jsonb" json:"context,omitempty"` - OccurredAt time.Time `gorm:"autoCreateTime" json:"occurred_at"` - - // Relations - Letter *LetterOutgoing `gorm:"foreignKey:LetterID" json:"letter,omitempty"` - ActorUser *User `gorm:"foreignKey:ActorUserID" json:"actor_user,omitempty"` - ActorDepartment *Department `gorm:"foreignKey:ActorDepartmentID" json:"actor_department,omitempty"` -} - -func (LetterOutgoingActivityLog) TableName() string { return "letter_outgoing_activity_logs" } - -// Action types for letter outgoing activity logs -const ( - LetterOutgoingActionCreated = "created" - LetterOutgoingActionUpdated = "updated" - LetterOutgoingActionDeleted = "deleted" - LetterOutgoingActionStatusChanged = "status_changed" - LetterOutgoingActionSubmittedApproval = "submitted_for_approval" - LetterOutgoingActionApproved = "approved" - LetterOutgoingActionRejected = "rejected" - LetterOutgoingActionRevised = "revised" - LetterOutgoingActionSent = "sent" - LetterOutgoingActionArchived = "archived" - LetterOutgoingActionAttachmentAdded = "attachment_added" - LetterOutgoingActionAttachmentRemoved = "attachment_removed" - LetterOutgoingActionRecipientAdded = "recipient_added" - LetterOutgoingActionRecipientRemoved = "recipient_removed" - LetterOutgoingActionDiscussionAdded = "discussion_added" -) \ No newline at end of file diff --git a/internal/entities/module.go b/internal/entities/module.go deleted file mode 100644 index b8704ce..0000000 --- a/internal/entities/module.go +++ /dev/null @@ -1,18 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type Module struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Name string `gorm:"not null" json:"name"` - Code string `gorm:"uniqueIndex;not null" json:"code"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - Permissions []Permission `gorm:"foreignKey:ModuleID" json:"permissions,omitempty"` -} - -func (Module) TableName() string { return "modules" } \ No newline at end of file diff --git a/internal/entities/priority.go b/internal/entities/priority.go deleted file mode 100644 index 8158935..0000000 --- a/internal/entities/priority.go +++ /dev/null @@ -1,17 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type Priority struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Name string `gorm:"not null;size:255" json:"name"` - Level int `gorm:"not null" json:"level"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` -} - -func (Priority) TableName() string { return "priorities" } diff --git a/internal/entities/rbac.go b/internal/entities/rbac.go deleted file mode 100644 index 60f7232..0000000 --- a/internal/entities/rbac.go +++ /dev/null @@ -1,42 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type Role struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Name string `gorm:"not null" json:"name"` - Code string `gorm:"uniqueIndex;not null" json:"code"` - Description string `json:"description"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` -} - -func (Role) TableName() string { return "roles" } - -type Permission struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - ModuleID *uuid.UUID `gorm:"type:uuid" json:"module_id"` - Module *Module `gorm:"foreignKey:ModuleID" json:"module,omitempty"` - Action string `json:"action"` - Code string `gorm:"uniqueIndex;not null" json:"code"` - Description string `json:"description"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` -} - -func (Permission) TableName() string { return "permissions" } - -type Position struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Name string `gorm:"not null" json:"name"` - Code string `gorm:"uniqueIndex" json:"code"` - Path string `gorm:"type:ltree;uniqueIndex" json:"path"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` -} - -func (Position) TableName() string { return "positions" } diff --git a/internal/entities/repository_attachment.go b/internal/entities/repository_attachment.go deleted file mode 100644 index faf4f96..0000000 --- a/internal/entities/repository_attachment.go +++ /dev/null @@ -1,19 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type RepositoryAttachment struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - FileURL string `gorm:"not null" json:"file_url"` - FileName string `gorm:"not null" json:"file_name"` - FileType string `gorm:"not null" json:"file_type"` - Category string `gorm:"not null" json:"category"` - UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"` - UploadedAt time.Time `gorm:"autoCreateTime" json:"uploaded_at"` -} - -func (RepositoryAttachment) TableName() string { return "repository_attachments" } diff --git a/internal/entities/role_permission.go b/internal/entities/role_permission.go deleted file mode 100644 index e4bd9c5..0000000 --- a/internal/entities/role_permission.go +++ /dev/null @@ -1,10 +0,0 @@ -package entities - -import "github.com/google/uuid" - -type RolePermission struct { - RoleID uuid.UUID `gorm:"type:uuid;primaryKey" json:"role_id"` - PermissionID uuid.UUID `gorm:"type:uuid;primaryKey" json:"permission_id"` -} - -func (RolePermission) TableName() string { return "role_permissions" } diff --git a/internal/entities/setting.go b/internal/entities/setting.go deleted file mode 100644 index 7fd5032..0000000 --- a/internal/entities/setting.go +++ /dev/null @@ -1,14 +0,0 @@ -package entities - -import ( - "time" -) - -type AppSetting struct { - Key string `gorm:"primaryKey;size:100" json:"key"` - Value JSONB `gorm:"type:jsonb;default:'{}'" json:"value"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` -} - -func (AppSetting) TableName() string { return "app_settings" } diff --git a/internal/entities/title.go b/internal/entities/title.go deleted file mode 100644 index 9e66942..0000000 --- a/internal/entities/title.go +++ /dev/null @@ -1,18 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" -) - -type Title struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Name string `gorm:"not null" json:"name"` - Code *string `gorm:"uniqueIndex" json:"code,omitempty"` - Description *string `json:"description,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` -} - -func (Title) TableName() string { return "titles" } diff --git a/internal/entities/user.go b/internal/entities/user.go index f5670fc..580ca89 100644 --- a/internal/entities/user.go +++ b/internal/entities/user.go @@ -49,7 +49,6 @@ type User struct { CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Profile *UserProfile `gorm:"foreignKey:UserID;references:ID" json:"profile,omitempty"` - Departments []Department `gorm:"many2many:user_department;foreignKey:ID;joinForeignKey:user_id;References:ID;joinReferences:department_id" json:"departments,omitempty"` } func (u *User) BeforeCreate(tx *gorm.DB) error { @@ -64,10 +63,6 @@ func (User) TableName() string { } func (u *User) HasPermission(permission string) bool { - return false -} - -func (u *User) CanAccessOutlet(outletID uuid.UUID) bool { - + // TODO: Implement permission checking logic return false } diff --git a/internal/handler/admin_approval_flow_handler.go b/internal/handler/admin_approval_flow_handler.go deleted file mode 100644 index 0adbead..0000000 --- a/internal/handler/admin_approval_flow_handler.go +++ /dev/null @@ -1,365 +0,0 @@ -package handler - -import ( - "context" - "eslogad-be/internal/appcontext" - "net/http" - "strconv" - - "eslogad-be/internal/contract" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -type ApprovalFlowService interface { - CreateApprovalFlow(ctx context.Context, req *contract.ApprovalFlowRequest) (*contract.ApprovalFlowResponse, error) - GetApprovalFlow(ctx context.Context, id uuid.UUID) (*contract.ApprovalFlowResponse, error) - GetApprovalFlowByDepartment(ctx context.Context, departmentID uuid.UUID) (*contract.ApprovalFlowResponse, error) - UpdateApprovalFlow(ctx context.Context, id uuid.UUID, req *contract.ApprovalFlowRequest) (*contract.ApprovalFlowResponse, error) - DeleteApprovalFlow(ctx context.Context, id uuid.UUID) error - ListApprovalFlows(ctx context.Context, req *contract.ListApprovalFlowsRequest) (*contract.ListApprovalFlowsResponse, error) -} - -type AdminApprovalFlowHandler struct { - svc ApprovalFlowService -} - -func NewAdminApprovalFlowHandler(svc ApprovalFlowService) *AdminApprovalFlowHandler { - return &AdminApprovalFlowHandler{svc: svc} -} - -func (h *AdminApprovalFlowHandler) CreateApprovalFlow(c *gin.Context) { - var req contract.ApprovalFlowRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - - if len(req.Steps) == 0 { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "at least one approval step is required", Code: http.StatusBadRequest}) - return - } - - for i, step := range req.Steps { - if step.ApproverRoleID == nil && step.ApproverUserID == nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "step " + strconv.Itoa(i+1) + " must have either approver_role_id or approver_user_id", - Code: http.StatusBadRequest, - }) - return - } - if step.ApproverRoleID != nil && step.ApproverUserID != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "step " + strconv.Itoa(i+1) + " cannot have both approver_role_id and approver_user_id", - Code: http.StatusBadRequest, - }) - return - } - } - - resp, err := h.svc.CreateApprovalFlow(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp)) -} - -func (h *AdminApprovalFlowHandler) GetApprovalFlow(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - resp, err := h.svc.GetApprovalFlow(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *AdminApprovalFlowHandler) GetApprovalFlowByDepartment(c *gin.Context) { - departmentID, err := uuid.Parse(c.Param("department_id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid department_id", Code: http.StatusBadRequest}) - return - } - - resp, err := h.svc.GetApprovalFlowByDepartment(c.Request.Context(), departmentID) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *AdminApprovalFlowHandler) UpdateApprovalFlow(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - var req contract.ApprovalFlowRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - - if len(req.Steps) == 0 { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "at least one approval step is required", Code: http.StatusBadRequest}) - return - } - - for i, step := range req.Steps { - if step.ApproverRoleID == nil && step.ApproverUserID == nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "step " + strconv.Itoa(i+1) + " must have either approver_role_id or approver_user_id", - Code: http.StatusBadRequest, - }) - return - } - if step.ApproverRoleID != nil && step.ApproverUserID != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "step " + strconv.Itoa(i+1) + " cannot have both approver_role_id and approver_user_id", - Code: http.StatusBadRequest, - }) - return - } - } - - resp, err := h.svc.UpdateApprovalFlow(c.Request.Context(), id, &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *AdminApprovalFlowHandler) DeleteApprovalFlow(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.DeleteApprovalFlow(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "approval flow deleted"}) -} - -func (h *AdminApprovalFlowHandler) ListApprovalFlows(c *gin.Context) { - // Parse query params - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) - - var departmentID *uuid.UUID - if departmentIDStr := c.Query("department_id"); departmentIDStr != "" { - if id, err := uuid.Parse(departmentIDStr); err == nil { - departmentID = &id - } - } - - var isActive *bool - if isActiveStr := c.Query("is_active"); isActiveStr != "" { - if active, err := strconv.ParseBool(isActiveStr); err == nil { - isActive = &active - } - } - - var search *string - if searchStr := c.Query("search"); searchStr != "" { - search = &searchStr - } - - // Build request - pass PAGE, bukan OFFSET - req := &contract.ListApprovalFlowsRequest{ - Page: page, // ✅ Pass page number - Limit: limit, - DepartmentID: departmentID, - IsActive: isActive, - Search: search, // tambahkan ini juga - } - - resp, err := h.svc.ListApprovalFlows(c.Request.Context(), req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ - Error: err.Error(), - Code: http.StatusInternalServerError, - }) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *AdminApprovalFlowHandler) ListApprovalFlowsByDepartment(c *gin.Context) { - appCtx := appcontext.FromGinContext(c.Request.Context()) - - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) - offset := (page - 1) * limit - - req := &contract.ListApprovalFlowsRequest{ - Limit: limit, - Page: offset, - DepartmentID: &appCtx.DepartmentID, - } - - resp, err := h.svc.ListApprovalFlows(c.Request.Context(), req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *AdminApprovalFlowHandler) ActivateApprovalFlow(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - // Get the current flow - flow, err := h.svc.GetApprovalFlow(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - // Update only the IsActive field - req := &contract.ApprovalFlowRequest{ - DepartmentID: flow.DepartmentID, - Name: flow.Name, - Description: flow.Description, - IsActive: true, - Steps: make([]contract.ApprovalFlowStepRequest, len(flow.Steps)), - } - - // Copy existing steps - for i, step := range flow.Steps { - req.Steps[i] = contract.ApprovalFlowStepRequest{ - StepOrder: step.StepOrder, - ParallelGroup: step.ParallelGroup, - ApproverRoleID: step.ApproverRoleID, - ApproverUserID: step.ApproverUserID, - Required: step.Required, - } - } - - resp, err := h.svc.UpdateApprovalFlow(c.Request.Context(), id, req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *AdminApprovalFlowHandler) DeactivateApprovalFlow(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - // Get the current flow - flow, err := h.svc.GetApprovalFlow(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - // Update only the IsActive field - req := &contract.ApprovalFlowRequest{ - DepartmentID: flow.DepartmentID, - Name: flow.Name, - Description: flow.Description, - IsActive: false, - Steps: make([]contract.ApprovalFlowStepRequest, len(flow.Steps)), - } - - // Copy existing steps - for i, step := range flow.Steps { - req.Steps[i] = contract.ApprovalFlowStepRequest{ - StepOrder: step.StepOrder, - ParallelGroup: step.ParallelGroup, - ApproverRoleID: step.ApproverRoleID, - ApproverUserID: step.ApproverUserID, - Required: step.Required, - } - } - - resp, err := h.svc.UpdateApprovalFlow(c.Request.Context(), id, req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *AdminApprovalFlowHandler) CloneApprovalFlow(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - var cloneReq struct { - DepartmentID uuid.UUID `json:"department_id" validate:"required"` - Name string `json:"name" validate:"required"` - } - - if err := c.ShouldBindJSON(&cloneReq); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - - // Get the source flow - sourceFlow, err := h.svc.GetApprovalFlow(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - // Create new flow request with cloned data - req := &contract.ApprovalFlowRequest{ - DepartmentID: cloneReq.DepartmentID, - Name: cloneReq.Name, - Description: sourceFlow.Description, - IsActive: false, // New cloned flow starts as inactive - Steps: make([]contract.ApprovalFlowStepRequest, len(sourceFlow.Steps)), - } - - // Copy steps from source flow - for i, step := range sourceFlow.Steps { - req.Steps[i] = contract.ApprovalFlowStepRequest{ - StepOrder: step.StepOrder, - ParallelGroup: step.ParallelGroup, - ApproverRoleID: step.ApproverRoleID, - ApproverUserID: step.ApproverUserID, - Required: step.Required, - } - } - - resp, err := h.svc.CreateApprovalFlow(c.Request.Context(), req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp)) -} diff --git a/internal/handler/analytics_handler.go b/internal/handler/analytics_handler.go deleted file mode 100644 index dda754a..0000000 --- a/internal/handler/analytics_handler.go +++ /dev/null @@ -1,195 +0,0 @@ -package handler - -import ( - "net/http" - - "eslogad-be/internal/contract" - "eslogad-be/internal/service" - - "github.com/gin-gonic/gin" -) - -type AnalyticsHandler struct { - analyticsService service.AnalyticsService -} - -func NewAnalyticsHandler(analyticsService service.AnalyticsService) *AnalyticsHandler { - return &AnalyticsHandler{ - analyticsService: analyticsService, - } -} - -// GetDashboard handles GET /api/v1/analytics/dashboard -func (h *AnalyticsHandler) GetDashboard(c *gin.Context) { - var req contract.AnalyticsDashboardRequest - - // Bind query parameters - if err := c.ShouldBindQuery(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "Invalid query parameters", - Code: http.StatusBadRequest, - }) - return - } - - // Get analytics dashboard data - response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ - Error: err.Error(), - Code: http.StatusInternalServerError, - }) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(response)) -} - -// GetLetterVolume handles GET /api/v1/analytics/volume -func (h *AnalyticsHandler) GetLetterVolume(c *gin.Context) { - response, err := h.analyticsService.GetLetterVolume(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ - Error: err.Error(), - Code: http.StatusInternalServerError, - }) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(response)) -} - -// GetStatusDistribution handles GET /api/v1/analytics/status-distribution -func (h *AnalyticsHandler) GetStatusDistribution(c *gin.Context) { - var req contract.AnalyticsDashboardRequest - - if err := c.ShouldBindQuery(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "Invalid query parameters", - Code: http.StatusBadRequest, - }) - return - } - - // Get full dashboard and extract status distribution - response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ - Error: err.Error(), - Code: http.StatusInternalServerError, - }) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{ - "status_distribution": response.StatusDistribution, - })) -} - -// GetPriorityDistribution handles GET /api/v1/analytics/priority-distribution -func (h *AnalyticsHandler) GetPriorityDistribution(c *gin.Context) { - var req contract.AnalyticsDashboardRequest - - if err := c.ShouldBindQuery(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "Invalid query parameters", - Code: http.StatusBadRequest, - }) - return - } - - // Get full dashboard and extract priority distribution - response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ - Error: err.Error(), - Code: http.StatusInternalServerError, - }) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{ - "priority_distribution": response.PriorityDistribution, - })) -} - -// GetDepartmentStats handles GET /api/v1/analytics/department-stats -func (h *AnalyticsHandler) GetDepartmentStats(c *gin.Context) { - var req contract.AnalyticsDashboardRequest - - if err := c.ShouldBindQuery(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "Invalid query parameters", - Code: http.StatusBadRequest, - }) - return - } - - // Get full dashboard and extract department stats - response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ - Error: err.Error(), - Code: http.StatusInternalServerError, - }) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{ - "department_stats": response.DepartmentStats, - })) -} - -// GetMonthlyTrend handles GET /api/v1/analytics/monthly-trend -func (h *AnalyticsHandler) GetMonthlyTrend(c *gin.Context) { - var req contract.AnalyticsDashboardRequest - - if err := c.ShouldBindQuery(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "Invalid query parameters", - Code: http.StatusBadRequest, - }) - return - } - - // Get full dashboard and extract monthly trend - response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ - Error: err.Error(), - Code: http.StatusInternalServerError, - }) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{ - "monthly_trend": response.MonthlyTrend, - })) -} - -// GetApprovalMetrics handles GET /api/v1/analytics/approval-metrics -func (h *AnalyticsHandler) GetApprovalMetrics(c *gin.Context) { - var req contract.AnalyticsDashboardRequest - - if err := c.ShouldBindQuery(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "Invalid query parameters", - Code: http.StatusBadRequest, - }) - return - } - - // Get full dashboard and extract approval metrics - response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ - Error: err.Error(), - Code: http.StatusInternalServerError, - }) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{ - "approval_metrics": response.ApprovalMetrics, - })) -} \ No newline at end of file diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go index f5a3217..9348d3d 100644 --- a/internal/handler/auth_handler.go +++ b/internal/handler/auth_handler.go @@ -1,13 +1,13 @@ package handler import ( - "eslogad-be/internal/util" + "go-backend-template/internal/util" "net/http" "strings" - "eslogad-be/internal/constants" - "eslogad-be/internal/contract" - "eslogad-be/internal/logger" + "go-backend-template/internal/constants" + "go-backend-template/internal/contract" + "go-backend-template/internal/logger" "github.com/gin-gonic/gin" ) @@ -54,82 +54,6 @@ func (h *AuthHandler) Login(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(loginResponse), "AuthHandler::Login") } -func (h *AuthHandler) Logout(c *gin.Context) { - token := h.extractTokenFromHeader(c) - if token == "" { - logger.FromContext(c.Request.Context()).Error("AuthHandler::Logout -> token is required") - h.sendErrorResponse(c, "Token is required", http.StatusUnauthorized) - return - } - - err := h.authService.Logout(c.Request.Context(), token) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::Logout -> Failed to logout") - h.sendErrorResponse(c, err.Error(), http.StatusUnauthorized) - return - } - - logger.FromContext(c.Request.Context()).Info("AuthHandler::Logout -> Successfully logged out") - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "Successfully logged out"}) -} - -func (h *AuthHandler) RefreshToken(c *gin.Context) { - token := h.extractTokenFromHeader(c) - if token == "" { - logger.FromContext(c.Request.Context()).Error("AuthHandler::RefreshToken -> token is required") - h.sendErrorResponse(c, "Token is required", http.StatusUnauthorized) - return - } - - loginResponse, err := h.authService.RefreshToken(c.Request.Context(), token) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::RefreshToken -> Failed to refresh token") - h.sendErrorResponse(c, err.Error(), http.StatusUnauthorized) - return - } - - logger.FromContext(c.Request.Context()).Infof("AuthHandler::RefreshToken -> Successfully refreshed token for user = %s", loginResponse.User.Email) - c.JSON(http.StatusOK, loginResponse) -} - -func (h *AuthHandler) ValidateToken(c *gin.Context) { - token := h.extractTokenFromHeader(c) - if token == "" { - logger.FromContext(c.Request.Context()).Error("AuthHandler::ValidateToken -> token is required") - h.sendErrorResponse(c, "Token is required", http.StatusUnauthorized) - return - } - - userResponse, err := h.authService.ValidateToken(token) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::ValidateToken -> Failed to validate token") - h.sendErrorResponse(c, err.Error(), http.StatusUnauthorized) - return - } - - logger.FromContext(c.Request.Context()).Infof("AuthHandler::ValidateToken -> Successfully validated token for user = %s", userResponse.Email) - c.JSON(http.StatusOK, userResponse) -} - -func (h *AuthHandler) GetProfile(c *gin.Context) { - token := h.extractTokenFromHeader(c) - if token == "" { - logger.FromContext(c.Request.Context()).Error("AuthHandler::GetProfile -> token is required") - h.sendErrorResponse(c, "Token is required", http.StatusUnauthorized) - return - } - - userResponse, err := h.authService.ValidateToken(token) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::GetProfile -> Failed to get profile") - h.sendErrorResponse(c, err.Error(), http.StatusUnauthorized) - return - } - - logger.FromContext(c.Request.Context()).Infof("AuthHandler::GetProfile -> Successfully retrieved profile for user = %s", userResponse.Email) - c.JSON(http.StatusOK, &contract.SuccessResponse{Data: userResponse, Message: "success get user profile"}) -} - func (h *AuthHandler) extractTokenFromHeader(c *gin.Context) string { authHeader := c.GetHeader("Authorization") if authHeader == "" { diff --git a/internal/handler/auth_service.go b/internal/handler/auth_service.go index fd8f65d..7aacf0f 100644 --- a/internal/handler/auth_service.go +++ b/internal/handler/auth_service.go @@ -2,12 +2,10 @@ package handler import ( "context" - "eslogad-be/internal/contract" + "go-backend-template/internal/contract" ) type AuthService interface { Login(ctx context.Context, req *contract.LoginRequest) (*contract.LoginResponse, error) - ValidateToken(tokenString string) (*contract.UserResponse, error) - RefreshToken(ctx context.Context, tokenString string) (*contract.LoginResponse, error) - Logout(ctx context.Context, tokenString string) error + RefreshToken(ctx context.Context, req *contract.RefreshTokenRequest) (*contract.LoginResponse, error) } diff --git a/internal/handler/disposition_route_handler.go b/internal/handler/disposition_route_handler.go deleted file mode 100644 index 816a664..0000000 --- a/internal/handler/disposition_route_handler.go +++ /dev/null @@ -1,164 +0,0 @@ -package handler - -import ( - "context" - "eslogad-be/internal/appcontext" - - "eslogad-be/internal/contract" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -type DispositionRouteService interface { - Create(ctx context.Context, req *contract.CreateDispositionRouteRequest) (*contract.DispositionRouteResponse, error) - 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 } - -func NewDispositionRouteHandler(svc DispositionRouteService) *DispositionRouteHandler { - 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: " + 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 - } - - // 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(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) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - var req contract.UpdateDispositionRouteRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400}) - return - } - resp, err := h.svc.Update(c.Request.Context(), id, &req) - if err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, contract.BuildSuccessResponse(resp)) -} - -func (h *DispositionRouteHandler) Get(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - resp, err := h.svc.Get(c.Request.Context(), id) - if err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, contract.BuildSuccessResponse(resp)) -} - -func (h *DispositionRouteHandler) ListByFromDept(c *gin.Context) { - appCtx := appcontext.FromGinContext(c.Request.Context()) - - resp, err := h.svc.ListByFromDept(c.Request.Context(), appCtx.DepartmentID) - if err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, contract.BuildSuccessResponse(resp)) -} - -func (h *DispositionRouteHandler) SetActive(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - toggle := c.Query("active") - active := toggle != "false" - if err := h.svc.SetActive(c.Request.Context(), id, active); err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, &contract.SuccessResponse{Message: "updated"}) -} - -// 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/dukcapil_handler.go b/internal/handler/dukcapil_handler.go new file mode 100644 index 0000000..efcbc9f --- /dev/null +++ b/internal/handler/dukcapil_handler.go @@ -0,0 +1,75 @@ +package handler + +import ( + "net/http" + "strings" + + "go-backend-template/internal/constants" + "go-backend-template/internal/contract" + "go-backend-template/internal/logger" + "go-backend-template/internal/util" + + "github.com/gin-gonic/gin" +) + +type DukcapilHandler struct { + dukcapilService DukcapilService +} + +func NewDukcapilHandler(dukcapilService DukcapilService) *DukcapilHandler { + return &DukcapilHandler{dukcapilService: dukcapilService} +} + +// FaceMatch handles POST /api/v1/dukcapil/face-match (1:N face recognition). +func (h *DukcapilHandler) FaceMatch(c *gin.Context) { + var req contract.FaceMatchRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("DukcapilHandler::FaceMatch -> request binding failed") + h.sendValidationError(c, "Invalid request body", constants.MalformedFieldErrorCode) + return + } + + if strings.TrimSpace(req.TransactionID) == "" { + h.sendValidationError(c, "transaction_id is required", constants.MissingFieldErrorCode) + return + } + if strings.TrimSpace(req.TransactionSource) == "" { + h.sendValidationError(c, "transaction_source is required", constants.MissingFieldErrorCode) + return + } + if strings.TrimSpace(req.Threshold) == "" { + h.sendValidationError(c, "threshold is required", constants.MissingFieldErrorCode) + return + } + if strings.TrimSpace(req.Image) == "" { + h.sendValidationError(c, "image is required (base64-encoded)", constants.MissingFieldErrorCode) + return + } + + res, err := h.dukcapilService.FaceMatch(c.Request.Context(), &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("DukcapilHandler::FaceMatch -> upstream call failed") + c.JSON(http.StatusBadGateway, &contract.ErrorResponse{ + Error: "upstream_error", + Message: err.Error(), + Code: http.StatusBadGateway, + Details: map[string]interface{}{"entity": constants.DukcapilHandlerEntity}, + }) + return + } + + logger.FromContext(c.Request.Context()).Infof("DukcapilHandler::FaceMatch -> tid=%s errorCode=%s matches=%d", res.TID, res.ErrorCode, len(res.Matches)) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(res), "DukcapilHandler::FaceMatch") +} + +func (h *DukcapilHandler) sendValidationError(c *gin.Context, message, code string) { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ + Error: "validation_error", + Message: message, + Code: http.StatusBadRequest, + Details: map[string]interface{}{ + "error_code": code, + "entity": constants.DukcapilHandlerEntity, + }, + }) +} diff --git a/internal/handler/dukcapil_service.go b/internal/handler/dukcapil_service.go new file mode 100644 index 0000000..7a19825 --- /dev/null +++ b/internal/handler/dukcapil_service.go @@ -0,0 +1,11 @@ +package handler + +import ( + "context" + + "go-backend-template/internal/contract" +) + +type DukcapilService interface { + FaceMatch(ctx context.Context, req *contract.FaceMatchRequest) (*contract.FaceMatchResponse, error) +} diff --git a/internal/handler/file_handler.go b/internal/handler/file_handler.go deleted file mode 100644 index 4a1442d..0000000 --- a/internal/handler/file_handler.go +++ /dev/null @@ -1,105 +0,0 @@ -package handler - -import ( - "context" - "io" - "net/http" - - "eslogad-be/internal/appcontext" - "eslogad-be/internal/contract" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -type FileService interface { - UploadProfileAvatar(ctx context.Context, userID uuid.UUID, filename string, content []byte, contentType string) (string, error) - UploadDocument(ctx context.Context, userID uuid.UUID, filename string, content []byte, contentType string) (string, string, error) - UploadDocumentFinal(ctx context.Context, userID uuid.UUID, filename string, content []byte, contentType string) (string, string, error) -} - -type FileHandler struct { - service FileService -} - -func NewFileHandler(service FileService) *FileHandler { - return &FileHandler{service: service} -} - -func (h *FileHandler) UploadProfileAvatar(c *gin.Context) { - appCtx := appcontext.FromGinContext(c.Request.Context()) - if appCtx.UserID == uuid.Nil { - c.JSON(http.StatusUnauthorized, &contract.ErrorResponse{Error: "Unauthorized", Code: http.StatusUnauthorized}) - return - } - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "file is required", Code: http.StatusBadRequest}) - return - } - defer file.Close() - content, err := io.ReadAll(io.LimitReader(file, 10<<20)) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "failed to read file", Code: http.StatusBadRequest}) - return - } - ct := header.Header.Get("Content-Type") - url, err := h.service.UploadProfileAvatar(c.Request.Context(), appCtx.UserID, header.Filename, content, ct) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]string{"url": url})) -} - -func (h *FileHandler) UploadDocument(c *gin.Context) { - appCtx := appcontext.FromGinContext(c.Request.Context()) - if appCtx.UserID == uuid.Nil { - c.JSON(http.StatusUnauthorized, &contract.ErrorResponse{Error: "Unauthorized", Code: http.StatusUnauthorized}) - return - } - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "file is required", Code: http.StatusBadRequest}) - return - } - defer file.Close() - content, err := io.ReadAll(io.LimitReader(file, 20<<20)) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "failed to read file", Code: http.StatusBadRequest}) - return - } - ct := header.Header.Get("Content-Type") - url, key, err := h.service.UploadDocument(c.Request.Context(), appCtx.UserID, header.Filename, content, ct) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]string{"url": url, "key": key})) -} - -func (h *FileHandler) UploadDocumentFinal(c *gin.Context) { - appCtx := appcontext.FromGinContext(c.Request.Context()) - if appCtx.UserID == uuid.Nil { - c.JSON(http.StatusUnauthorized, &contract.ErrorResponse{Error: "Unauthorized", Code: http.StatusUnauthorized}) - return - } - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "file is required", Code: http.StatusBadRequest}) - return - } - defer file.Close() - content, err := io.ReadAll(io.LimitReader(file, 20<<20)) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "failed to read file", Code: http.StatusBadRequest}) - return - } - ct := header.Header.Get("Content-Type") - url, key, err := h.service.UploadDocumentFinal(c.Request.Context(), appCtx.UserID, header.Filename, content, ct) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]string{"url": url, "key": key})) -} diff --git a/internal/handler/health.go b/internal/handler/health.go index ae2b821..9d8cb58 100644 --- a/internal/handler/health.go +++ b/internal/handler/health.go @@ -1,7 +1,7 @@ package handler import ( - "eslogad-be/internal/logger" + "go-backend-template/internal/logger" "net/http" "github.com/gin-gonic/gin" diff --git a/internal/handler/letter_handler.go b/internal/handler/letter_handler.go deleted file mode 100644 index ceb7add..0000000 --- a/internal/handler/letter_handler.go +++ /dev/null @@ -1,509 +0,0 @@ -package handler - -import ( - "context" - "eslogad-be/internal/appcontext" - "fmt" - "net/http" - "strconv" - "strings" - - "eslogad-be/internal/contract" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -type LetterService interface { - CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) - GetIncomingLetterByID(ctx context.Context, id uuid.UUID) (*contract.IncomingLetterResponse, error) - ListIncomingLetters(ctx context.Context, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error) - SearchIncomingLetters(ctx context.Context, req *contract.SearchIncomingLettersRequest) (*contract.SearchIncomingLettersResponse, 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 - BulkSoftDeleteIncomingLetters(ctx context.Context, ids []uuid.UUID) error - BulkArchiveIncomingLetters(ctx context.Context, letterIDs []uuid.UUID) (*contract.BulkArchiveLettersResponse, error) - ArchiveIncomingLetter(ctx context.Context, letterID uuid.UUID) error - - CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) - GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) - - CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) - UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) - - 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 { - svc LetterService -} - -func NewLetterHandler(svc LetterService) *LetterHandler { - return &LetterHandler{svc: svc} -} - -// Helper functions for common patterns -func (h *LetterHandler) parseUUID(c *gin.Context, param string) (uuid.UUID, bool) { - id, err := uuid.Parse(c.Param(param)) - if err != nil { - h.respondError(c, http.StatusBadRequest, "invalid "+param) - return uuid.Nil, false - } - return id, true -} - -func (h *LetterHandler) bindJSON(c *gin.Context, req interface{}) bool { - if err := c.ShouldBindJSON(req); err != nil { - h.respondError(c, http.StatusBadRequest, "invalid request body") - return false - } - return true -} - -func (h *LetterHandler) bindQuery(c *gin.Context, req interface{}) bool { - if err := c.ShouldBindQuery(req); err != nil { - h.respondError(c, http.StatusBadRequest, "invalid query parameters") - return false - } - return true -} - -func (h *LetterHandler) respondError(c *gin.Context, code int, message string) { - c.JSON(code, &contract.ErrorResponse{ - Error: message, - Code: code, - }) -} - -func (h *LetterHandler) respondSuccess(c *gin.Context, code int, data interface{}) { - c.JSON(code, contract.BuildSuccessResponse(data)) -} - -func (h *LetterHandler) handleServiceError(c *gin.Context, err error) { - if err != nil { - h.respondError(c, http.StatusInternalServerError, err.Error()) - } -} - -func (h *LetterHandler) CreateIncomingLetter(c *gin.Context) { - var req contract.CreateIncomingLetterRequest - if !h.bindJSON(c, &req) { - return - } - - resp, err := h.svc.CreateIncomingLetter(c.Request.Context(), &req) - if err != nil { - h.handleServiceError(c, err) - return - } - - h.respondSuccess(c, http.StatusCreated, resp) -} - -func (h *LetterHandler) GetIncomingLetter(c *gin.Context) { - id, ok := h.parseUUID(c, "id") - if !ok { - return - } - - resp, err := h.svc.GetIncomingLetterByID(c.Request.Context(), id) - if err != nil { - h.handleServiceError(c, err) - return - } - - h.respondSuccess(c, http.StatusOK, resp) -} - -func (h *LetterHandler) ListIncomingLetters(c *gin.Context) { - req := h.parseListRequest(c) - - resp, err := h.svc.ListIncomingLetters(c.Request.Context(), req) - if err != nil { - h.handleServiceError(c, err) - return - } - - 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 - - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) - - // Ensure valid pagination values - if page < 1 { - page = 1 - } - if limit < 1 { - limit = 10 - } - if limit > 100 { - limit = 100 - } - - req := &contract.ListIncomingLettersRequest{ - Page: page, - Limit: limit, - } - - 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 -} - -func (h *LetterHandler) UpdateIncomingLetter(c *gin.Context) { - id, ok := h.parseUUID(c, "id") - if !ok { - return - } - - var req contract.UpdateIncomingLetterRequest - if !h.bindJSON(c, &req) { - return - } - - resp, err := h.svc.UpdateIncomingLetter(c.Request.Context(), id, &req) - if err != nil { - h.handleServiceError(c, err) - return - } - - h.respondSuccess(c, http.StatusOK, resp) -} - -func (h *LetterHandler) DeleteIncomingLetter(c *gin.Context) { - id, ok := h.parseUUID(c, "id") - if !ok { - return - } - - if err := h.svc.SoftDeleteIncomingLetter(c.Request.Context(), id); err != nil { - h.handleServiceError(c, err) - return - } - - h.respondSuccess(c, http.StatusOK, &contract.SuccessResponse{ - Message: "Letter deleted successfully", - }) -} - -func (h *LetterHandler) BulkDeleteIncomingLetters(c *gin.Context) { - var req struct { - IDs []uuid.UUID `json:"ids" binding:"required,min=1"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, contract.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - return - } - - if err := h.svc.BulkSoftDeleteIncomingLetters(c.Request.Context(), req.IDs); err != nil { - h.handleServiceError(c, err) - return - } - - h.respondSuccess(c, http.StatusOK, &contract.SuccessResponse{ - Message: fmt.Sprintf("%d letters deleted successfully", len(req.IDs)), - }) -} - -func (h *LetterHandler) CreateDispositions(c *gin.Context) { - var req contract.CreateLetterDispositionRequest - if !h.bindJSON(c, &req) { - return - } - - appCtx := appcontext.FromGinContext(c.Request.Context()) - req.FromDepartment = appCtx.DepartmentID - - resp, err := h.svc.CreateDispositions(c.Request.Context(), &req) - if err != nil { - h.handleServiceError(c, err) - return - } - - h.respondSuccess(c, http.StatusCreated, resp) -} - -func (h *LetterHandler) GetEnhancedDispositionsByLetter(c *gin.Context) { - letterID, ok := h.parseUUID(c, "letter_id") - if !ok { - return - } - - resp, err := h.svc.GetEnhancedDispositionsByLetter(c.Request.Context(), letterID) - if err != nil { - h.handleServiceError(c, err) - return - } - - h.respondSuccess(c, http.StatusOK, resp) -} - -func (h *LetterHandler) CreateDiscussion(c *gin.Context) { - letterID, ok := h.parseUUID(c, "letter_id") - if !ok { - return - } - - var req contract.CreateLetterDiscussionRequest - if !h.bindJSON(c, &req) { - return - } - - resp, err := h.svc.CreateDiscussion(c.Request.Context(), letterID, &req) - if err != nil { - h.handleServiceError(c, err) - return - } - - h.respondSuccess(c, http.StatusCreated, resp) -} - -func (h *LetterHandler) UpdateDiscussion(c *gin.Context) { - letterID, ok := h.parseUUID(c, "letter_id") - if !ok { - return - } - - discussionID, ok := h.parseUUID(c, "discussion_id") - if !ok { - return - } - - var req contract.UpdateLetterDiscussionRequest - if !h.bindJSON(c, &req) { - return - } - - resp, err := h.svc.UpdateDiscussion(c.Request.Context(), letterID, discussionID, &req) - if err != nil { - h.handleServiceError(c, err) - return - } - - h.respondSuccess(c, http.StatusOK, resp) -} - -func (h *LetterHandler) SearchIncomingLetters(c *gin.Context) { - var req contract.SearchIncomingLettersRequest - if !h.bindQuery(c, &req) { - return - } - - if req.Page <= 0 { - req.Page = 1 - } - if req.Limit <= 0 { - req.Limit = 10 - } - if req.SortOrder == "" { - req.SortOrder = "desc" - } - if req.SortBy == "" { - req.SortBy = "created_at" - } - - resp, err := h.svc.SearchIncomingLetters(c.Request.Context(), &req) - if err != nil { - h.handleServiceError(c, err) - return - } - - 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) -} - -func (h *LetterHandler) ArchiveIncomingLetter(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - h.respondError(c, http.StatusBadRequest, "invalid id") - return - } - - err = h.svc.ArchiveIncomingLetter(c.Request.Context(), id) - if err != nil { - h.handleServiceError(c, err) - return - } - - h.respondSuccess(c, http.StatusOK, &contract.SuccessResponse{Message: "archived"}) -} diff --git a/internal/handler/letter_outgoing_handler.go b/internal/handler/letter_outgoing_handler.go deleted file mode 100644 index 3b131b5..0000000 --- a/internal/handler/letter_outgoing_handler.go +++ /dev/null @@ -1,640 +0,0 @@ -package handler - -import ( - "context" - "eslogad-be/internal/appcontext" - "eslogad-be/internal/contract" - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "gorm.io/gorm" -) - -type LetterOutgoingService interface { - CreateOutgoingLetter(ctx context.Context, req *contract.CreateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) - GetOutgoingLetterByID(ctx context.Context, id uuid.UUID) (*contract.OutgoingLetterResponse, error) - ListOutgoingLetters(ctx context.Context, req *contract.ListOutgoingLettersRequest) (*contract.ListOutgoingLettersResponse, error) - SearchOutgoingLetters(ctx context.Context, req *contract.SearchOutgoingLettersRequest) (*contract.SearchOutgoingLettersResponse, error) - UpdateOutgoingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) - DeleteOutgoingLetter(ctx context.Context, id uuid.UUID) error - BulkDeleteOutgoingLetters(ctx context.Context, ids []uuid.UUID) error - - SubmitForApproval(ctx context.Context, letterID uuid.UUID) error - ApproveOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.ApproveLetterRequest) error - RejectOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.RejectLetterRequest) error - ReviseOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.ReviseLetterRequest) error - SendOutgoingLetter(ctx context.Context, letterID uuid.UUID) error - ArchiveOutgoingLetter(ctx context.Context, letterID uuid.UUID) error - - AddRecipients(ctx context.Context, letterID uuid.UUID, req *contract.AddRecipientsRequest) error - UpdateRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID, req *contract.UpdateRecipientRequest) error - RemoveRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID) error - - AddAttachments(ctx context.Context, letterID uuid.UUID, req *contract.AddAttachmentsRequest) error - RemoveAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error - - AddFinalAttachments(ctx context.Context, letterID uuid.UUID, req *contract.AddAttachmentsRequest) error - RemoveFinalAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error - - CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateDiscussionRequest) (*contract.DiscussionResponse, error) - UpdateDiscussion(ctx context.Context, discussionID uuid.UUID, req *contract.UpdateDiscussionRequest) error - DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error - - GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error) - 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 { - svc LetterOutgoingService -} - -func NewLetterOutgoingHandler(svc LetterOutgoingService) *LetterOutgoingHandler { - return &LetterOutgoingHandler{svc: svc} -} - -func (h *LetterOutgoingHandler) CreateOutgoingLetter(c *gin.Context) { - var req contract.CreateOutgoingLetterRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - ctx := c.Request.Context() - req.UserID = appcontext.FromGinContext(ctx).UserID - - resp, err := h.svc.CreateOutgoingLetter(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp)) -} - -func (h *LetterOutgoingHandler) GetOutgoingLetter(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - resp, err := h.svc.GetOutgoingLetterByID(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *LetterOutgoingHandler) ListOutgoingLetters(c *gin.Context) { - var req contract.ListOutgoingLettersRequest - - if err := c.ShouldBindQuery(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid query parameters", Code: http.StatusBadRequest}) - return - } - - if req.Page <= 0 { - req.Page = 1 - } - - if req.Limit <= 0 { - req.Limit = 10 - } - - if ids := c.QueryArray("priority_ids[]"); len(ids) > 0 { - for _, s := range ids { - if id, err := uuid.Parse(s); err == nil { - req.PriorityIDs = append(req.PriorityIDs, id) - } - } - } - - fmt.Printf("[DEBUG] request: %v\n", req) - fmt.Printf("[DEBUG] Raw query: %v\n", c.Request.URL.RawQuery) - fmt.Printf("[DEBUG] Parsed form: %v\n", c.Request.URL.Query()) - - resp, err := h.svc.ListOutgoingLetters(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *LetterOutgoingHandler) UpdateOutgoingLetter(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - var req contract.UpdateOutgoingLetterRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - - resp, err := h.svc.UpdateOutgoingLetter(c.Request.Context(), id, &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *LetterOutgoingHandler) DeleteOutgoingLetter(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.DeleteOutgoingLetter(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(&contract.SuccessResponse{Message: "deleted"})) -} - -func (h *LetterOutgoingHandler) BulkDeleteOutgoingLetters(c *gin.Context) { - var req struct { - IDs []uuid.UUID `json:"ids" binding:"required,min=1"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "Invalid request body", - Code: http.StatusBadRequest, - }) - return - } - - if err := h.svc.BulkDeleteOutgoingLetters(c.Request.Context(), req.IDs); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ - Error: err.Error(), - Code: http.StatusInternalServerError, - }) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(&contract.SuccessResponse{ - Message: fmt.Sprintf("%d letters deleted successfully", len(req.IDs)), - })) -} - -func (h *LetterOutgoingHandler) SubmitForApproval(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.SubmitForApproval(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "submitted for approval"}) -} - -func (h *LetterOutgoingHandler) ApproveOutgoingLetter(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - var req contract.ApproveLetterRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.ApproveOutgoingLetter(c.Request.Context(), id, &req); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(&contract.SuccessResponse{Message: "approved"})) -} - -func (h *LetterOutgoingHandler) RejectOutgoingLetter(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - var req contract.RejectLetterRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.RejectOutgoingLetter(c.Request.Context(), id, &req); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(&contract.SuccessResponse{Message: "rejected"})) -} - -func (h *LetterOutgoingHandler) ReviseOutgoingLetter(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - var req contract.ReviseLetterRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.ReviseOutgoingLetter(c.Request.Context(), id, &req); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(&contract.SuccessResponse{Message: "revised"})) -} - -func (h *LetterOutgoingHandler) SendOutgoingLetter(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.SendOutgoingLetter(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "sent"}) -} - -func (h *LetterOutgoingHandler) ArchiveOutgoingLetter(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.ArchiveOutgoingLetter(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "archived"}) -} - -func (h *LetterOutgoingHandler) AddRecipients(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - var req contract.AddRecipientsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.AddRecipients(c.Request.Context(), id, &req); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "recipients added"}) -} - -func (h *LetterOutgoingHandler) UpdateRecipient(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid letter id", Code: http.StatusBadRequest}) - return - } - - recipientID, err := uuid.Parse(c.Param("recipient_id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid recipient id", Code: http.StatusBadRequest}) - return - } - - var req contract.UpdateRecipientRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.UpdateRecipient(c.Request.Context(), id, recipientID, &req); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "recipient updated"}) -} - -func (h *LetterOutgoingHandler) RemoveRecipient(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid letter id", Code: http.StatusBadRequest}) - return - } - - recipientID, err := uuid.Parse(c.Param("recipient_id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid recipient id", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.RemoveRecipient(c.Request.Context(), id, recipientID); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "recipient removed"}) -} - -func (h *LetterOutgoingHandler) AddAttachments(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - var req contract.AddAttachmentsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.AddAttachments(c.Request.Context(), id, &req); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(&contract.SuccessResponse{Message: "attachment added"})) -} - -func (h *LetterOutgoingHandler) RemoveAttachment(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid letter id", Code: http.StatusBadRequest}) - return - } - - attachmentID, err := uuid.Parse(c.Param("attachment_id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid attachment id", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.RemoveAttachment(c.Request.Context(), id, attachmentID); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "attachment removed"}) -} - -func (h *LetterOutgoingHandler) AddFinalAttachments(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - var req contract.AddAttachmentsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.AddFinalAttachments(c.Request.Context(), id, &req); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(&contract.SuccessResponse{Message: "attachment added"})) -} - -func (h *LetterOutgoingHandler) RemoveFinalAttachment(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid letter id", Code: http.StatusBadRequest}) - return - } - - attachmentID, err := uuid.Parse(c.Param("attachment_id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid attachment id", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.RemoveFinalAttachment(c.Request.Context(), id, attachmentID); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "attachment removed"}) -} - -func (h *LetterOutgoingHandler) CreateDiscussion(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid letter id", Code: http.StatusBadRequest}) - return - } - - var req contract.CreateDiscussionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - - resp, err := h.svc.CreateDiscussion(c.Request.Context(), id, &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp)) -} - -func (h *LetterOutgoingHandler) UpdateDiscussion(c *gin.Context) { - discussionID, err := uuid.Parse(c.Param("discussion_id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid discussion id", Code: http.StatusBadRequest}) - return - } - - var req contract.UpdateDiscussionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.UpdateDiscussion(c.Request.Context(), discussionID, &req); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "discussion updated"}) -} - -func (h *LetterOutgoingHandler) DeleteDiscussion(c *gin.Context) { - discussionID, err := uuid.Parse(c.Param("discussion_id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid discussion id", Code: http.StatusBadRequest}) - return - } - - if err := h.svc.DeleteDiscussion(c.Request.Context(), discussionID); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "discussion deleted"}) -} - -func (h *LetterOutgoingHandler) SearchOutgoingLetters(c *gin.Context) { - var req contract.SearchOutgoingLettersRequest - if err := c.ShouldBindQuery(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid query parameters", Code: http.StatusBadRequest}) - return - } - - if req.Page <= 0 { - req.Page = 1 - } - if req.Limit <= 0 { - req.Limit = 10 - } - if req.SortOrder == "" { - req.SortOrder = "desc" - } - if req.SortBy == "" { - req.SortBy = "created_at" - } - - resp, err := h.svc.SearchOutgoingLetters(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *LetterOutgoingHandler) GetLetterApprovalInfo(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - resp, err := h.svc.GetLetterApprovalInfo(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -// GetLetterApprovals returns all approvals and their status for a letter -func (h *LetterOutgoingHandler) GetLetterApprovals(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - resp, err := h.svc.GetLetterApprovals(c.Request.Context(), id) - if err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "letter not found", Code: http.StatusNotFound}) - return - } - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -// GetApprovalDiscussions returns both approvals and discussions for an outgoing letter -func (h *LetterOutgoingHandler) GetApprovalDiscussions(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - resp, err := h.svc.GetApprovalDiscussions(c.Request.Context(), id) - if err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "letter not found", Code: http.StatusNotFound}) - return - } - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -// GetApprovalTimeline returns a chronological timeline of approval and discussion events -func (h *LetterOutgoingHandler) GetApprovalTimeline(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) - return - } - - resp, err := h.svc.GetApprovalTimeline(c.Request.Context(), id) - if err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "letter not found", Code: http.StatusNotFound}) - return - } - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) - return - } - - 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.go b/internal/handler/master_handler.go deleted file mode 100644 index 1c40c6f..0000000 --- a/internal/handler/master_handler.go +++ /dev/null @@ -1,388 +0,0 @@ -package handler - -import ( - "context" - "net/http" - - "eslogad-be/internal/contract" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "gorm.io/gorm" -) - -type MasterService interface { - CreateLabel(ctx context.Context, req *contract.CreateLabelRequest) (*contract.LabelResponse, error) - UpdateLabel(ctx context.Context, id uuid.UUID, req *contract.UpdateLabelRequest) (*contract.LabelResponse, error) - DeleteLabel(ctx context.Context, id uuid.UUID) error - ListLabels(ctx context.Context) (*contract.ListLabelsResponse, error) - - CreatePriority(ctx context.Context, req *contract.CreatePriorityRequest) (*contract.PriorityResponse, error) - UpdatePriority(ctx context.Context, id uuid.UUID, req *contract.UpdatePriorityRequest) (*contract.PriorityResponse, error) - DeletePriority(ctx context.Context, id uuid.UUID) error - ListPriorities(ctx context.Context) (*contract.ListPrioritiesResponse, error) - - CreateInstitution(ctx context.Context, req *contract.CreateInstitutionRequest) (*contract.InstitutionResponse, error) - UpdateInstitution(ctx context.Context, id uuid.UUID, req *contract.UpdateInstitutionRequest) (*contract.InstitutionResponse, error) - DeleteInstitution(ctx context.Context, id uuid.UUID) error - ListInstitutions(ctx context.Context, req *contract.ListInstitutionsRequest) (*contract.ListInstitutionsResponse, error) - - CreateDispositionAction(ctx context.Context, req *contract.CreateDispositionActionRequest) (*contract.DispositionActionResponse, error) - UpdateDispositionAction(ctx context.Context, id uuid.UUID, req *contract.UpdateDispositionActionRequest) (*contract.DispositionActionResponse, error) - DeleteDispositionAction(ctx context.Context, id uuid.UUID) error - ListDispositionActions(ctx context.Context) (*contract.ListDispositionActionsResponse, error) - - CreateDepartment(ctx context.Context, req *contract.CreateDepartmentRequest) (*contract.GetDepartmentResponse, error) - GetDepartment(ctx context.Context, id uuid.UUID) (*contract.GetDepartmentResponse, error) - UpdateDepartment(ctx context.Context, id uuid.UUID, req *contract.UpdateDepartmentRequest) (*contract.GetDepartmentResponse, error) - DeleteDepartment(ctx context.Context, id uuid.UUID) error - ListDepartments(ctx context.Context, req *contract.ListDepartmentsRequest) (*contract.ListDepartmentsResponse, error) - GetOrganizationalChart(ctx context.Context, rootPath string) (*contract.OrganizationalChartResponse, error) - GetOrganizationalChartByID(ctx context.Context, departmentID uuid.UUID) (*contract.OrganizationalChartResponse, error) -} - -type MasterHandler struct{ svc MasterService } - -func NewMasterHandler(svc MasterService) *MasterHandler { return &MasterHandler{svc: svc} } - -func (h *MasterHandler) CreateLabel(c *gin.Context) { - var req contract.CreateLabelRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400}) - return - } - resp, err := h.svc.CreateLabel(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp)) -} - -func (h *MasterHandler) UpdateLabel(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - var req contract.UpdateLabelRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400}) - return - } - resp, err := h.svc.UpdateLabel(c.Request.Context(), id, &req) - if err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, contract.BuildSuccessResponse(resp)) -} -func (h *MasterHandler) DeleteLabel(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - if err := h.svc.DeleteLabel(c.Request.Context(), id); err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, &contract.SuccessResponse{Message: "deleted"}) -} -func (h *MasterHandler) ListLabels(c *gin.Context) { - resp, err := h.svc.ListLabels(c.Request.Context()) - if err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, contract.BuildSuccessResponse(resp)) -} - -// Priorities -func (h *MasterHandler) CreatePriority(c *gin.Context) { - var req contract.CreatePriorityRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400}) - return - } - resp, err := h.svc.CreatePriority(c.Request.Context(), &req) - if err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(201, contract.BuildSuccessResponse(resp)) -} -func (h *MasterHandler) UpdatePriority(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - var req contract.UpdatePriorityRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400}) - return - } - resp, err := h.svc.UpdatePriority(c.Request.Context(), id, &req) - if err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, contract.BuildSuccessResponse(resp)) -} -func (h *MasterHandler) DeletePriority(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - if err := h.svc.DeletePriority(c.Request.Context(), id); err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, &contract.SuccessResponse{Message: "deleted"}) -} -func (h *MasterHandler) ListPriorities(c *gin.Context) { - resp, err := h.svc.ListPriorities(c.Request.Context()) - if err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, contract.BuildSuccessResponse(resp)) -} - -// Institutions -func (h *MasterHandler) CreateInstitution(c *gin.Context) { - var req contract.CreateInstitutionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400}) - return - } - resp, err := h.svc.CreateInstitution(c.Request.Context(), &req) - if err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(201, contract.BuildSuccessResponse(resp)) -} - -func (h *MasterHandler) UpdateInstitution(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - var req contract.UpdateInstitutionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400}) - return - } - resp, err := h.svc.UpdateInstitution(c.Request.Context(), id, &req) - if err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, contract.BuildSuccessResponse(resp)) -} - -func (h *MasterHandler) DeleteInstitution(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - if err := h.svc.DeleteInstitution(c.Request.Context(), id); err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, &contract.SuccessResponse{Message: "deleted"}) -} - -func (h *MasterHandler) ListInstitutions(c *gin.Context) { - var req contract.ListInstitutionsRequest - - if search := c.Query("search"); search != "" { - req.Search = &search - } - - resp, err := h.svc.ListInstitutions(c.Request.Context(), &req) - if err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, contract.BuildSuccessResponse(resp)) -} - -// Disposition Actions -func (h *MasterHandler) CreateDispositionAction(c *gin.Context) { - var req contract.CreateDispositionActionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400}) - return - } - resp, err := h.svc.CreateDispositionAction(c.Request.Context(), &req) - if err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(201, contract.BuildSuccessResponse(resp)) -} -func (h *MasterHandler) UpdateDispositionAction(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - var req contract.UpdateDispositionActionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid body", Code: 400}) - return - } - resp, err := h.svc.UpdateDispositionAction(c.Request.Context(), id, &req) - if err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, contract.BuildSuccessResponse(resp)) -} -func (h *MasterHandler) DeleteDispositionAction(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(400, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - if err := h.svc.DeleteDispositionAction(c.Request.Context(), id); err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, &contract.SuccessResponse{Message: "deleted"}) -} -func (h *MasterHandler) ListDispositionActions(c *gin.Context) { - resp, err := h.svc.ListDispositionActions(c.Request.Context()) - if err != nil { - c.JSON(500, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(200, contract.BuildSuccessResponse(resp)) -} - -// Departments -func (h *MasterHandler) CreateDepartment(c *gin.Context) { - var req contract.CreateDepartmentRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400}) - return - } - resp, err := h.svc.CreateDepartment(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp)) -} - -func (h *MasterHandler) GetDepartment(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - resp, err := h.svc.GetDepartment(c.Request.Context(), id) - if err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "department not found", Code: 404}) - return - } - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *MasterHandler) UpdateDepartment(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - var req contract.UpdateDepartmentRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400}) - return - } - resp, err := h.svc.UpdateDepartment(c.Request.Context(), id, &req) - if err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "department not found", Code: 404}) - return - } - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *MasterHandler) DeleteDepartment(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - if err := h.svc.DeleteDepartment(c.Request.Context(), id); err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "department not found", Code: 404}) - return - } - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "department deleted successfully"}) -} - -func (h *MasterHandler) ListDepartments(c *gin.Context) { - var req contract.ListDepartmentsRequest - - // Parse query parameters - if err := c.ShouldBindQuery(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid query parameters", Code: 400}) - return - } - - resp, err := h.svc.ListDepartments(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *MasterHandler) GetOrganizationalChart(c *gin.Context) { - // Get optional root path from query parameter - rootPath := c.Query("root_path") - - resp, err := h.svc.GetOrganizationalChart(c.Request.Context(), rootPath) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *MasterHandler) GetOrganizationalChartByID(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid department id", Code: 400}) - return - } - - resp, err := h.svc.GetOrganizationalChartByID(c.Request.Context(), id) - if err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "department not found", Code: 404}) - return - } - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} diff --git a/internal/handler/notification_handler.go b/internal/handler/notification_handler.go deleted file mode 100644 index 1d25f1e..0000000 --- a/internal/handler/notification_handler.go +++ /dev/null @@ -1,230 +0,0 @@ -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/handler/onlyoffice_handler.go b/internal/handler/onlyoffice_handler.go deleted file mode 100644 index 0490aed..0000000 --- a/internal/handler/onlyoffice_handler.go +++ /dev/null @@ -1,205 +0,0 @@ -package handler - -import ( - "context" - "eslogad-be/internal/contract" - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -type OnlyOfficeService interface { - ProcessCallback(ctx context.Context, documentKey string, req *contract.OnlyOfficeCallbackRequest) (*contract.OnlyOfficeCallbackResponse, error) - GetEditorConfig(ctx context.Context, req *contract.GetEditorConfigRequest) (*contract.GetEditorConfigResponse, error) - LockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error - UnlockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error - GetDocumentSession(ctx context.Context, documentKey string) (*contract.DocumentSession, error) - GetOnlyOfficeConfig(ctx context.Context) (*contract.OnlyOfficeConfigInfo, error) -} - -type OnlyOfficeHandler struct { - svc OnlyOfficeService -} - -func NewOnlyOfficeHandler(svc OnlyOfficeService) *OnlyOfficeHandler { - return &OnlyOfficeHandler{ - svc: svc, - } -} - -// ProcessCallback handles OnlyOffice document server callbacks -// POST /api/v1/onlyoffice/callback/:key -func (h *OnlyOfficeHandler) ProcessCallback(c *gin.Context) { - documentKey := c.Param("key") - if documentKey == "" { - c.JSON(http.StatusBadRequest, &contract.OnlyOfficeCallbackResponse{Error: 1}) - return - } - - var req contract.OnlyOfficeCallbackRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.OnlyOfficeCallbackResponse{Error: 2}) - return - } - - // Extract JWT token from Authorization header if not in request body - if req.Token == "" { - authHeader := c.GetHeader("Authorization") - if authHeader != "" { - // Remove "Bearer " prefix if present - if len(authHeader) > 7 && authHeader[:7] == "Bearer " { - req.Token = authHeader[7:] - } else { - req.Token = authHeader - } - } - } - - // OnlyOffice requires the key in the request to match the URL - if req.Key != "" && req.Key != documentKey { - c.JSON(http.StatusBadRequest, &contract.OnlyOfficeCallbackResponse{Error: 1}) - return - } - req.Key = documentKey - - resp, err := h.svc.ProcessCallback(c.Request.Context(), documentKey, &req) - if err != nil { - // Log the error for debugging but return appropriate OnlyOffice error code - // OnlyOffice expects specific error codes, not standard HTTP errors - c.JSON(http.StatusOK, &contract.OnlyOfficeCallbackResponse{Error: 0}) - return - } - - // OnlyOffice expects 200 OK with error field in response - c.JSON(http.StatusOK, resp) -} -func (h *OnlyOfficeHandler) GetEditorConfig(c *gin.Context) { - var req contract.GetEditorConfigRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: fmt.Sprintf("invalid request body: %v", err), - Code: http.StatusBadRequest, - }) - return - } - - resp, err := h.svc.GetEditorConfig(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ - Error: err.Error(), - Code: http.StatusInternalServerError, - }) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -// LockDocument locks a document for editing -// POST /api/v1/onlyoffice/lock/:id -func (h *OnlyOfficeHandler) LockDocument(c *gin.Context) { - documentID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "invalid document id", - Code: http.StatusBadRequest, - }) - return - } - - // Get user ID from context - userCtx := c.MustGet("user").(map[string]interface{}) - userID, err := uuid.Parse(userCtx["user_id"].(string)) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "invalid user context", - Code: http.StatusBadRequest, - }) - return - } - - if err := h.svc.LockDocument(c.Request.Context(), documentID, userID); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ - Error: err.Error(), - Code: http.StatusInternalServerError, - }) - return - } - - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "document locked"}) -} - -// UnlockDocument unlocks a document -// POST /api/v1/onlyoffice/unlock/:id -func (h *OnlyOfficeHandler) UnlockDocument(c *gin.Context) { - documentID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "invalid document id", - Code: http.StatusBadRequest, - }) - return - } - - // Get user ID from context - userCtx := c.MustGet("user").(map[string]interface{}) - userID, err := uuid.Parse(userCtx["user_id"].(string)) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "invalid user context", - Code: http.StatusBadRequest, - }) - return - } - - if err := h.svc.UnlockDocument(c.Request.Context(), documentID, userID); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ - Error: err.Error(), - Code: http.StatusInternalServerError, - }) - return - } - - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "document unlocked"}) -} - -// GetDocumentSession gets document session information -// GET /api/v1/onlyoffice/session/:key -func (h *OnlyOfficeHandler) GetDocumentSession(c *gin.Context) { - documentKey := c.Param("key") - if documentKey == "" { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ - Error: "document key is required", - Code: http.StatusBadRequest, - }) - return - } - - session, err := h.svc.GetDocumentSession(c.Request.Context(), documentKey) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ - Error: err.Error(), - Code: http.StatusInternalServerError, - }) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(session)) -} - -// GetOnlyOfficeConfig returns the OnlyOffice configuration -// GET /api/v1/onlyoffice/config -func (h *OnlyOfficeHandler) GetOnlyOfficeConfig(c *gin.Context) { - config, err := h.svc.GetOnlyOfficeConfig(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ - Error: err.Error(), - Code: http.StatusInternalServerError, - }) - return - } - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(config)) -} diff --git a/internal/handler/rbac_handler.go b/internal/handler/rbac_handler.go deleted file mode 100644 index 7efd797..0000000 --- a/internal/handler/rbac_handler.go +++ /dev/null @@ -1,182 +0,0 @@ -package handler - -import ( - "context" - "net/http" - - "eslogad-be/internal/contract" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -type RBACService interface { - CreatePermission(ctx context.Context, req *contract.CreatePermissionRequest) (*contract.PermissionResponse, error) - UpdatePermission(ctx context.Context, id uuid.UUID, req *contract.UpdatePermissionRequest) (*contract.PermissionResponse, error) - DeletePermission(ctx context.Context, id uuid.UUID) error - ListPermissions(ctx context.Context) (*contract.ListPermissionsResponse, error) - - CreateRole(ctx context.Context, req *contract.CreateRoleRequest) (*contract.RoleWithPermissionsResponse, error) - UpdateRole(ctx context.Context, id uuid.UUID, req *contract.UpdateRoleRequest) (*contract.RoleWithPermissionsResponse, error) - DeleteRole(ctx context.Context, id uuid.UUID) error - ListRoles(ctx context.Context) (*contract.ListRolesResponse, error) - - // New methods - GetPermissionsGrouped(ctx context.Context) (*contract.PermissionsGroupedResponse, error) - CreateOrUpdateRole(ctx context.Context, req *contract.CreateOrUpdateRoleRequest) (*contract.RoleDetailResponse, error) - GetRoleDetail(ctx context.Context, roleID uuid.UUID) (*contract.RoleDetailResponse, error) -} - -type RBACHandler struct{ svc RBACService } - -func NewRBACHandler(svc RBACService) *RBACHandler { return &RBACHandler{svc: svc} } - -func (h *RBACHandler) CreatePermission(c *gin.Context) { - var req contract.CreatePermissionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) - return - } - resp, err := h.svc.CreatePermission(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp)) -} - -func (h *RBACHandler) UpdatePermission(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - var req contract.UpdatePermissionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400}) - return - } - resp, err := h.svc.UpdatePermission(c.Request.Context(), id, &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *RBACHandler) DeletePermission(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - if err := h.svc.DeletePermission(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "deleted"}) -} - -func (h *RBACHandler) ListPermissions(c *gin.Context) { - resp, err := h.svc.ListPermissions(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *RBACHandler) CreateRole(c *gin.Context) { - var req contract.CreateRoleRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400}) - return - } - resp, err := h.svc.CreateRole(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp)) -} - -func (h *RBACHandler) UpdateRole(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - var req contract.UpdateRoleRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400}) - return - } - resp, err := h.svc.UpdateRole(c.Request.Context(), id, &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -func (h *RBACHandler) DeleteRole(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - if err := h.svc.DeleteRole(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "deleted"}) -} - -func (h *RBACHandler) ListRoles(c *gin.Context) { - resp, err := h.svc.ListRoles(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} - -// New handlers for the required API endpoints -func (h *RBACHandler) GetPermissionsGrouped(c *gin.Context) { - resp, err := h.svc.GetPermissionsGrouped(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, resp) -} - -func (h *RBACHandler) CreateOrUpdateRole(c *gin.Context) { - var req contract.CreateOrUpdateRoleRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body: " + err.Error(), Code: 400}) - return - } - - resp, err := h.svc.CreateOrUpdateRole(c.Request.Context(), &req) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, resp) -} - -func (h *RBACHandler) GetRoleDetail(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400}) - return - } - - resp, err := h.svc.GetRoleDetail(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) - return - } - c.JSON(http.StatusOK, resp) -} diff --git a/internal/handler/repository_attachment_handler.go b/internal/handler/repository_attachment_handler.go deleted file mode 100644 index 9079155..0000000 --- a/internal/handler/repository_attachment_handler.go +++ /dev/null @@ -1,137 +0,0 @@ -package handler - -import ( - "eslogad-be/internal/constants" - "eslogad-be/internal/contract" - "eslogad-be/internal/logger" - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -type RepositoryAttachmentHandler struct { - attachmentService RepositoryAttachmentService -} - -func NewRepositoryAttachmentHandler(attachmentService RepositoryAttachmentService) *RepositoryAttachmentHandler { - return &RepositoryAttachmentHandler{ - attachmentService: attachmentService, - } -} - -func (h *RepositoryAttachmentHandler) CreateAttachment(c *gin.Context) { - var req contract.CreateRepositoryAttachmentRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::CreateAttachment -> request binding failed") - h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) - return - } - - userResponse, err := h.attachmentService.CreateAttachment(c.Request.Context(), &req) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::CreateAttachment -> Failed to create user from service") - h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) - return - } - - logger.FromContext(c).Infof("UserHandler::CreateUser -> Successfully created repository attachment = %+v", userResponse) - c.JSON(http.StatusOK, contract.BuildSuccessResponse(userResponse)) -} - -func (h *RepositoryAttachmentHandler) DeleteAttachment(c *gin.Context) { - attachmentIDStr := c.Param("id") - attachmentID, err := uuid.Parse(attachmentIDStr) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::DeleteAttachment -> Invalid attachment id") - h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode) - return - } - - err = h.attachmentService.DeleteAttachment(c.Request.Context(), attachmentID) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::DeleteAttachment -> Failed to delete attachment from service") - h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) - return - } - - logger.FromContext(c).Info("UserHandler::DeleteAttachment -> Successfully deleted attachment") - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "User deleted successfully"}) -} - -func (h *RepositoryAttachmentHandler) GetAttachment(c *gin.Context) { - attachmentIDStr := c.Param("id") - attachmentID, err := uuid.Parse(attachmentIDStr) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::GetAttachment -> Invalid attachment ID") - h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode) - return - } - - attachmentResponse, err := h.attachmentService.GetById(c.Request.Context(), attachmentID) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::GetAttachment -> Failed to get attachment from service") - h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) - return - } - - logger.FromContext(c).Infof("UserHandler::GetAttachment -> Successfully retrieved attachment = %+v", attachmentResponse) - c.JSON(http.StatusOK, attachmentResponse) -} -func (h *RepositoryAttachmentHandler) ListAttachment(c *gin.Context) { - ctx := c.Request.Context() - - req := &contract.ListRepositoryAttachmentsRequest{ - Page: 1, - Limit: 10, - } - - if page := c.Query("page"); page != "" { - if p, err := strconv.Atoi(page); err == nil { - req.Page = p - } - } - - if limit := c.Query("limit"); limit != "" { - if l, err := strconv.Atoi(limit); err == nil { - req.Limit = l - } - } - - attachmentsResponse, err := h.attachmentService.ListAttachment(ctx, req) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::ListUsers -> Failed to list users from service") - h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) - return - } - - logger.FromContext(c).Infof("UserHandler::ListUsers -> Successfully listed users = %+v", attachmentsResponse) - c.JSON(http.StatusOK, contract.BuildSuccessResponse(attachmentsResponse)) -} - -func (h *RepositoryAttachmentHandler) sendValidationErrorResponse(c *gin.Context, message string, errorCode string) { - statusCode := constants.HttpErrorMap[errorCode] - if statusCode == 0 { - statusCode = http.StatusBadRequest - } - - errorResponse := &contract.ErrorResponse{ - Error: message, - Code: statusCode, - Details: map[string]interface{}{ - "error_code": errorCode, - "entity": constants.UserValidatorEntity, - }, - } - c.JSON(statusCode, errorResponse) -} - -func (h *RepositoryAttachmentHandler) sendErrorResponse(c *gin.Context, message string, statusCode int) { - errorResponse := &contract.ErrorResponse{ - Error: message, - Code: statusCode, - Details: map[string]interface{}{}, - } - c.JSON(statusCode, errorResponse) -} diff --git a/internal/handler/repository_attachment_service.go b/internal/handler/repository_attachment_service.go deleted file mode 100644 index 52d397e..0000000 --- a/internal/handler/repository_attachment_service.go +++ /dev/null @@ -1,15 +0,0 @@ -package handler - -import ( - "context" - "eslogad-be/internal/contract" - - "github.com/google/uuid" -) - -type RepositoryAttachmentService interface { - CreateAttachment(ctx context.Context, req *contract.CreateRepositoryAttachmentRequest) (*contract.RepositoryAttachmentsResponse, error) - DeleteAttachment(ctx context.Context, id uuid.UUID) error - GetById(ctx context.Context, id uuid.UUID) (*contract.RepositoryAttachmentsResponse, error) - ListAttachment(ctx context.Context, req *contract.ListRepositoryAttachmentsRequest) (*contract.ListRepositoryAttachmentsResponse, error) -} diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index a963a5b..eb1a483 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -4,10 +4,9 @@ import ( "net/http" "strconv" - "eslogad-be/internal/appcontext" - "eslogad-be/internal/constants" - "eslogad-be/internal/contract" - "eslogad-be/internal/logger" + "go-backend-template/internal/constants" + "go-backend-template/internal/contract" + "go-backend-template/internal/logger" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -149,248 +148,32 @@ func (h *UserHandler) GetUser(c *gin.Context) { func (h *UserHandler) ListUsers(c *gin.Context) { ctx := c.Request.Context() - req := &contract.ListUsersRequest{ - Page: 1, - Limit: 10, - } + page := 1 + limit := 10 - if page := c.Query("page"); page != "" { - if p, err := strconv.Atoi(page); err == nil { - req.Page = p + if pageStr := c.Query("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil { + page = p } } - if limit := c.Query("limit"); limit != "" { - if l, err := strconv.Atoi(limit); err == nil { - req.Limit = l + if limitStr := c.Query("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil { + limit = l } } - var roleParam *string - if role := c.Query("role"); role != "" { - roleParam = &role - req.Role = &role - } - - if roleCode := c.Query("role_code"); roleCode != "" { - req.RoleCode = &roleCode - } - - if req.RoleCode == nil && roleParam != nil { - req.RoleCode = roleParam - } - - if search := c.Query("search"); search != "" { - req.Search = &search - } - - if isActiveStr := c.Query("is_active"); isActiveStr != "" { - if isActive, err := strconv.ParseBool(isActiveStr); err == nil { - req.IsActive = &isActive - } - } - - validationError, validationErrorCode := h.userValidator.ValidateListUsersRequest(req) - if validationError != nil { - logger.FromContext(c).WithError(validationError).Error("UserHandler::ListUsers -> request validation failed") - h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) - return - } - - usersResponse, err := h.userService.ListUsers(ctx, req) + usersResponse, err := h.userService.GetUsers(ctx, page, limit) if err != nil { logger.FromContext(c).WithError(err).Error("UserHandler::ListUsers -> Failed to list users from service") h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) return } - logger.FromContext(c).Infof("UserHandler::ListUsers -> Successfully listed users = %+v", usersResponse) + logger.FromContext(c).Infof("UserHandler::ListUsers -> Successfully listed users") c.JSON(http.StatusOK, contract.BuildSuccessResponse(usersResponse)) } -func (h *UserHandler) ChangePassword(c *gin.Context) { - userIDStr := c.Param("id") - userID, err := uuid.Parse(userIDStr) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::ChangePassword -> Invalid user ID") - h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode) - return - } - - validationError, validationErrorCode := h.userValidator.ValidateUserID(userID) - if validationError != nil { - logger.FromContext(c).WithError(validationError).Error("UserHandler::ChangePassword -> user ID validation failed") - h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) - return - } - - var req contract.ChangePasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::ChangePassword -> request binding failed") - h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) - return - } - - validationError, validationErrorCode = h.userValidator.ValidateChangePasswordRequest(&req) - if validationError != nil { - logger.FromContext(c).WithError(validationError).Error("UserHandler::ChangePassword -> request validation failed") - h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) - return - } - - err = h.userService.ChangePassword(c.Request.Context(), userID, &req) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::ChangePassword -> Failed to change password from service") - h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) - return - } - - logger.FromContext(c).Info("UserHandler::ChangePassword -> Successfully changed password") - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "Password changed successfully"}) -} - -func (h *UserHandler) ChangeUserPassword(c *gin.Context) { - userIDStr := c.Param("id") - userID, err := uuid.Parse(userIDStr) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::ChangeUserPassword -> Invalid user ID") - h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode) - return - } - - validationError, validationErrorCode := h.userValidator.ValidateUserID(userID) - if validationError != nil { - logger.FromContext(c).WithError(validationError).Error("UserHandler::ChangeUserPassword -> user ID validation failed") - h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) - return - } - - var req contract.ChangeUserPasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::ChangeUserPassword -> request binding failed") - h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) - return - } - - err = h.userService.ChangeUserPassword(c.Request.Context(), userID, &req) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::ChangeUserPassword -> Failed to change password from service") - h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) - return - } - - logger.FromContext(c).Info("UserHandler::ChangeUserPassword -> Successfully changed password") - c.JSON(http.StatusOK, &contract.NewSuccessResponse{Success: true, Message: "Password changed successfully"}) -} - -func (h *UserHandler) GetProfile(c *gin.Context) { - appCtx := appcontext.FromGinContext(c.Request.Context()) - if appCtx.UserID == uuid.Nil { - h.sendErrorResponse(c, "Unauthorized", http.StatusUnauthorized) - return - } - profile, err := h.userService.GetProfile(c.Request.Context(), appCtx.UserID) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::GetProfile -> Failed to get profile") - h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) - return - } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(profile)) -} - -func (h *UserHandler) UpdateProfile(c *gin.Context) { - appCtx := appcontext.FromGinContext(c.Request.Context()) - if appCtx.UserID == uuid.Nil { - h.sendErrorResponse(c, "Unauthorized", http.StatusUnauthorized) - return - } - var req contract.UpdateUserProfileRequest - if err := c.ShouldBindJSON(&req); err != nil { - h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) - return - } - updated, err := h.userService.UpdateProfile(c.Request.Context(), appCtx.UserID, &req) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::UpdateProfile -> Failed to update profile") - h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) - return - } - c.JSON(http.StatusOK, contract.BuildSuccessResponse(updated)) -} - -func (h *UserHandler) ListTitles(c *gin.Context) { - titles, err := h.userService.ListTitles(c.Request.Context()) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::ListTitles -> Failed to get titles from service") - h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) - return - } - - logger.FromContext(c).Infof("UserHandler::ListTitles -> Successfully retrieved titles = %+v", titles) - c.JSON(http.StatusOK, titles) -} - -func (h *UserHandler) GetUserProfile(c *gin.Context) { - userIDStr := c.Param("id") - userID, err := uuid.Parse(userIDStr) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::GetUserProfile -> Invalid user ID") - h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode) - return - } - - validationError, validationErrorCode := h.userValidator.ValidateUserID(userID) - if validationError != nil { - logger.FromContext(c).WithError(validationError).Error("UserHandler::GetUserProfile -> user ID validation failed") - h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) - return - } - - profile, err := h.userService.GetProfile(c.Request.Context(), userID) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::GetUserProfile -> Failed to get user profile from service") - h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) - return - } - - logger.FromContext(c).Infof("UserHandler::GetUserProfile -> Successfully retrieved user profile for user ID = %s", userID) - c.JSON(http.StatusOK, contract.BuildSuccessResponse(profile)) -} - -func (h *UserHandler) GetActiveUsersForMention(c *gin.Context) { - search := c.Query("search") - limitStr := c.DefaultQuery("limit", "50") - - limit, err := strconv.Atoi(limitStr) - if err != nil || limit <= 0 { - limit = 50 - } - if limit > 100 { - limit = 100 - } - - var searchPtr *string - if search != "" { - searchPtr = &search - } - - users, err := h.userService.GetActiveUsersForMention(c.Request.Context(), searchPtr, limit) - if err != nil { - logger.FromContext(c).WithError(err).Error("UserHandler::GetActiveUsersForMention -> Failed to get active users from service") - h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) - return - } - - response := contract.MentionUsersResponse{ - Users: users, - Count: len(users), - } - - logger.FromContext(c).Infof("UserHandler::GetActiveUsersForMention -> Successfully retrieved %d active users", len(users)) - - c.JSON(http.StatusOK, contract.BuildSuccessResponse(response)) -} - func (h *UserHandler) sendErrorResponse(c *gin.Context, message string, statusCode int) { errorResponse := &contract.ErrorResponse{ Error: message, diff --git a/internal/handler/user_service.go b/internal/handler/user_service.go index d692a41..335d523 100644 --- a/internal/handler/user_service.go +++ b/internal/handler/user_service.go @@ -2,7 +2,7 @@ package handler import ( "context" - "eslogad-be/internal/contract" + "go-backend-template/internal/contract" "github.com/google/uuid" ) @@ -12,16 +12,5 @@ type UserService interface { UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) DeleteUser(ctx context.Context, id uuid.UUID) error GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) - GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error) - ListUsers(ctx context.Context, req *contract.ListUsersRequest) (*contract.ListUsersResponse, error) - ChangePassword(ctx context.Context, userID uuid.UUID, req *contract.ChangePasswordRequest) error - ChangeUserPassword(ctx context.Context, userID uuid.UUID, req *contract.ChangeUserPasswordRequest) error - - GetProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) - UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) - - ListTitles(ctx context.Context) (*contract.ListTitlesResponse, error) - - // Get active users for mention purposes - GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) + GetUsers(ctx context.Context, page, limit int) (*contract.PaginatedUserResponse, error) } diff --git a/internal/handler/user_validator.go b/internal/handler/user_validator.go index a294244..e838d2a 100644 --- a/internal/handler/user_validator.go +++ b/internal/handler/user_validator.go @@ -1,7 +1,7 @@ package handler import ( - "eslogad-be/internal/contract" + "go-backend-template/internal/contract" "github.com/google/uuid" ) @@ -12,5 +12,4 @@ type UserValidator interface { ValidateListUsersRequest(req *contract.ListUsersRequest) (error, string) ValidateChangePasswordRequest(req *contract.ChangePasswordRequest) (error, string) ValidateUserID(userID uuid.UUID) (error, string) - ValidateUpdateUserOutletRequest(req *contract.UpdateUserOutletRequest) (error, string) } diff --git a/internal/logger/app_logger.go b/internal/logger/app_logger.go index 9d3f449..48759f5 100644 --- a/internal/logger/app_logger.go +++ b/internal/logger/app_logger.go @@ -2,7 +2,7 @@ package logger import ( "context" - "eslogad-be/internal/appcontext" + "go-backend-template/internal/appcontext" "log" "os" diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go index 6271096..bd099d8 100644 --- a/internal/middleware/auth_middleware.go +++ b/internal/middleware/auth_middleware.go @@ -1,13 +1,13 @@ package middleware import ( - "eslogad-be/internal/appcontext" + "go-backend-template/internal/appcontext" "net/http" "strings" - "eslogad-be/internal/constants" - "eslogad-be/internal/contract" - "eslogad-be/internal/logger" + "go-backend-template/internal/constants" + "go-backend-template/internal/contract" + "go-backend-template/internal/logger" "github.com/gin-gonic/gin" ) @@ -22,51 +22,6 @@ func NewAuthMiddleware(authService AuthValidateService) *AuthMiddleware { } } -func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc { - return func(c *gin.Context) { - token := m.extractTokenFromHeader(c) - if token == "" { - logger.FromContext(c.Request.Context()).Error("AuthMiddleware::RequireAuth -> Missing authorization token") - m.sendErrorResponse(c, "Authorization token is required", http.StatusUnauthorized) - c.Abort() - return - } - - userResponse, err := m.authService.ValidateToken(token) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("AuthMiddleware::RequireAuth -> Invalid token") - m.sendErrorResponse(c, "Invalid or expired token", http.StatusUnauthorized) - c.Abort() - return - } - - setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String()) - setKeyInContext(c, appcontext.UserNameKey, userResponse.Name) - - if len(userResponse.DepartmentResponse) > 0 { - departmentID := userResponse.DepartmentResponse[0].ID.String() - setKeyInContext(c, appcontext.DepartmentIDKey, departmentID) - } else { - setKeyInContext(c, appcontext.DepartmentIDKey, "") - } - - if len(userResponse.Roles) > 0 { - userRole := userResponse.Roles[0].Code - setKeyInContext(c, appcontext.UserRoleKey, userRole) - } else { - setKeyInContext(c, appcontext.UserRoleKey, "") - } - - if roles, perms, err := m.authService.ExtractAccess(token); err == nil { - c.Set("user_roles", roles) - c.Set("user_permissions", perms) - } - - logger.FromContext(c.Request.Context()).Infof("AuthMiddleware::RequireAuth -> User authenticated: %s", userResponse.Email) - c.Next() - } -} - func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc { return func(c *gin.Context) { appCtx := appcontext.FromGinContext(c.Request.Context()) diff --git a/internal/middleware/auth_service.go b/internal/middleware/auth_service.go index 189ca64..344a280 100644 --- a/internal/middleware/auth_service.go +++ b/internal/middleware/auth_service.go @@ -1,13 +1,4 @@ package middleware -import ( - "context" - "eslogad-be/internal/contract" -) - type AuthValidateService interface { - ValidateToken(tokenString string) (*contract.UserResponse, error) - RefreshToken(ctx context.Context, tokenString string) (*contract.LoginResponse, error) - Logout(ctx context.Context, tokenString string) error - ExtractAccess(tokenString string) (roles []string, permissions []string, err error) } diff --git a/internal/middleware/context.go b/internal/middleware/context.go index 891e4ac..b6a5472 100644 --- a/internal/middleware/context.go +++ b/internal/middleware/context.go @@ -2,8 +2,8 @@ package middleware import ( "context" - "eslogad-be/internal/appcontext" - "eslogad-be/internal/constants" + "go-backend-template/internal/appcontext" + "go-backend-template/internal/constants" "github.com/gin-gonic/gin" ) diff --git a/internal/middleware/correlation_id.go b/internal/middleware/correlation_id.go index 4629465..aa634c1 100644 --- a/internal/middleware/correlation_id.go +++ b/internal/middleware/correlation_id.go @@ -2,8 +2,8 @@ package middleware import ( "context" - "eslogad-be/internal/appcontext" - "eslogad-be/internal/constants" + "go-backend-template/internal/appcontext" + "go-backend-template/internal/constants" "github.com/gin-gonic/gin" "github.com/google/uuid" ) diff --git a/internal/middleware/recover.go b/internal/middleware/recover.go index 32ea840..07792f0 100644 --- a/internal/middleware/recover.go +++ b/internal/middleware/recover.go @@ -1,9 +1,9 @@ package middleware import ( - "eslogad-be/internal/contract" - "eslogad-be/internal/logger" - "eslogad-be/internal/util" + "go-backend-template/internal/contract" + "go-backend-template/internal/logger" + "go-backend-template/internal/util" "github.com/gin-gonic/gin" "net/http" "runtime/debug" diff --git a/internal/middleware/stat_logger.go b/internal/middleware/stat_logger.go index 74311c5..860e3eb 100644 --- a/internal/middleware/stat_logger.go +++ b/internal/middleware/stat_logger.go @@ -1,8 +1,8 @@ package middleware import ( - "eslogad-be/internal/constants" - "eslogad-be/internal/logger" + "go-backend-template/internal/constants" + "go-backend-template/internal/logger" "fmt" "github.com/gin-gonic/gin" "net/http" diff --git a/internal/middleware/user_id_resolver.go b/internal/middleware/user_id_resolver.go index 3aff85b..de80aec 100644 --- a/internal/middleware/user_id_resolver.go +++ b/internal/middleware/user_id_resolver.go @@ -1,8 +1,8 @@ package middleware import ( - "eslogad-be/internal/contract" - "eslogad-be/internal/logger" + "go-backend-template/internal/contract" + "go-backend-template/internal/logger" "github.com/gin-gonic/gin" "github.com/google/uuid" diff --git a/internal/middleware/user_processor.go b/internal/middleware/user_processor.go index 54e552e..66cae95 100644 --- a/internal/middleware/user_processor.go +++ b/internal/middleware/user_processor.go @@ -2,7 +2,7 @@ package middleware import ( "context" - "eslogad-be/internal/contract" + "go-backend-template/internal/contract" "github.com/google/uuid" ) diff --git a/internal/processor/activity_log_processor.go b/internal/processor/activity_log_processor.go deleted file mode 100644 index f40e6df..0000000 --- a/internal/processor/activity_log_processor.go +++ /dev/null @@ -1,62 +0,0 @@ -package processor - -import ( - "context" - - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" - - "github.com/google/uuid" -) - -type ActivityLogProcessorImpl struct { - repo *repository.LetterIncomingActivityLogRepository -} - -func NewActivityLogProcessor(repo *repository.LetterIncomingActivityLogRepository) *ActivityLogProcessorImpl { - return &ActivityLogProcessorImpl{repo: repo} -} - -func (p *ActivityLogProcessorImpl) Log(ctx context.Context, letterID uuid.UUID, actionType string, actorUserID *uuid.UUID, actorDepartmentID *uuid.UUID, targetType *string, targetID *uuid.UUID, fromStatus *string, toStatus *string, contextData map[string]interface{}) error { - ctxJSON := entities.JSONB{} - for k, v := range contextData { - ctxJSON[k] = v - } - entry := &entities.LetterIncomingActivityLog{ - LetterID: letterID, - ActionType: actionType, - ActorUserID: actorUserID, - ActorDepartmentID: actorDepartmentID, - TargetType: targetType, - TargetID: targetID, - FromStatus: fromStatus, - ToStatus: toStatus, - Context: ctxJSON, - } - return p.repo.Create(ctx, entry) -} - -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 deleted file mode 100644 index 98414a9..0000000 --- a/internal/processor/cached_user_processor.go +++ /dev/null @@ -1,112 +0,0 @@ -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, userRoleProc UserRoleProcessor) *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 deleted file mode 100644 index e5c5044..0000000 --- a/internal/processor/cached_user_wrapper.go +++ /dev/null @@ -1,33 +0,0 @@ -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/department_processor.go b/internal/processor/department_processor.go deleted file mode 100644 index 70c4827..0000000 --- a/internal/processor/department_processor.go +++ /dev/null @@ -1,270 +0,0 @@ -package processor - -import ( - "context" - "fmt" - - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" - - "github.com/google/uuid" -) - -// DepartmentProcessor handles all department-related business logic -type DepartmentProcessor interface { - Create(ctx context.Context, department *entities.Department) error - Get(ctx context.Context, id uuid.UUID) (*entities.Department, error) - GetByPath(ctx context.Context, path string) (*entities.Department, error) - Update(ctx context.Context, department *entities.Department) error - Delete(ctx context.Context, id uuid.UUID) error - List(ctx context.Context, search string, limit, offset int) ([]entities.Department, int64, error) - - // Hierarchy operations - GetChildren(ctx context.Context, parentID uuid.UUID) ([]entities.Department, error) - GetAllDescendants(ctx context.Context, parentID uuid.UUID) ([]entities.Department, error) - UpdateChildrenPaths(ctx context.Context, oldPath, newPath string) error - ValidateHierarchy(ctx context.Context, departmentID, newParentID uuid.UUID) error - - // Tree building operations - GetDepartmentWithDescendants(ctx context.Context, departmentID uuid.UUID) ([]entities.Department, error) - GetDepartmentWithParentAndSiblings(ctx context.Context, departmentID uuid.UUID) ([]entities.Department, error) - GetAllDepartments(ctx context.Context) ([]entities.Department, error) - GetDepartmentsByRootPath(ctx context.Context, rootPath string) ([]entities.Department, error) -} - -type DepartmentProcessorImpl struct { - departmentRepo *repository.DepartmentRepository - txManager *repository.TxManager -} - -// NewDepartmentProcessor creates a new department processor -func NewDepartmentProcessor( - departmentRepo *repository.DepartmentRepository, - txManager *repository.TxManager, -) *DepartmentProcessorImpl { - return &DepartmentProcessorImpl{ - departmentRepo: departmentRepo, - txManager: txManager, - } -} - -// Create creates a new department -func (p *DepartmentProcessorImpl) Create(ctx context.Context, department *entities.Department) error { - // Build path based on parent - if department.ParentDepartmentID != nil && *department.ParentDepartmentID != uuid.Nil { - parent, err := p.departmentRepo.GetByID(ctx, *department.ParentDepartmentID) - if err != nil { - return fmt.Errorf("parent department not found: %w", err) - } - department.Path = parent.Path + "." + department.Code - } else { - department.Path = department.Code - } - - return p.departmentRepo.Create(ctx, department) -} - -// Get retrieves a department by ID -func (p *DepartmentProcessorImpl) Get(ctx context.Context, id uuid.UUID) (*entities.Department, error) { - return p.departmentRepo.Get(ctx, id) -} - -// GetByPath retrieves a department by its path -func (p *DepartmentProcessorImpl) GetByPath(ctx context.Context, path string) (*entities.Department, error) { - return p.departmentRepo.GetByPath(ctx, path) -} - -// Update updates a department with hierarchy validation -func (p *DepartmentProcessorImpl) Update(ctx context.Context, department *entities.Department) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - // Get the current department state - current, err := p.departmentRepo.Get(txCtx, department.ID) - if err != nil { - return err - } - - // Store old values for comparison - oldPath := current.Path - oldParentID := current.ParentDepartmentID - pathChanged := false - - // Update path if parent or code changed - if department.ParentDepartmentID != oldParentID || department.Code != current.Code { - // Rebuild path - if department.ParentDepartmentID == nil { - department.Path = department.Code - } else { - parent, err := p.departmentRepo.GetByID(txCtx, *department.ParentDepartmentID) - if err != nil { - return fmt.Errorf("parent department not found: %w", err) - } - department.Path = parent.Path + "." + department.Code - } - - if department.Path != oldPath { - pathChanged = true - } - } - - // Update the department - if err := p.departmentRepo.Update(txCtx, department); err != nil { - return err - } - - // Update children paths if necessary - if pathChanged { - if err := p.updateChildrenPathsRecursively(txCtx, department.ID, department.Path); err != nil { - return fmt.Errorf("failed to update children paths: %w", err) - } - } - - return nil - }) -} - -// Delete deletes a department -func (p *DepartmentProcessorImpl) Delete(ctx context.Context, id uuid.UUID) error { - return p.departmentRepo.Delete(ctx, id) -} - -// List lists departments with pagination -func (p *DepartmentProcessorImpl) List(ctx context.Context, search string, limit, offset int) ([]entities.Department, int64, error) { - return p.departmentRepo.List(ctx, search, limit, offset) -} - -// GetChildren gets direct children of a department -func (p *DepartmentProcessorImpl) GetChildren(ctx context.Context, parentID uuid.UUID) ([]entities.Department, error) { - // First get the parent department to get its path - parent, err := p.departmentRepo.Get(ctx, parentID) - if err != nil { - return nil, err - } - if parent == nil { - return []entities.Department{}, nil - } - return p.departmentRepo.GetChildren(ctx, parent.Path) -} - -// GetAllDescendants gets all descendants of a department -func (p *DepartmentProcessorImpl) GetAllDescendants(ctx context.Context, parentID uuid.UUID) ([]entities.Department, error) { - return p.departmentRepo.GetAllDescendants(ctx, parentID) -} - -// UpdateChildrenPaths updates paths for all children -func (p *DepartmentProcessorImpl) UpdateChildrenPaths(ctx context.Context, oldPath, newPath string) error { - return p.departmentRepo.UpdateChildrenPaths(ctx, oldPath, newPath) -} - -// ValidateHierarchy validates that setting newParentID won't create circular references -func (p *DepartmentProcessorImpl) ValidateHierarchy(ctx context.Context, departmentID, newParentID uuid.UUID) error { - // Can't be its own parent - if departmentID == newParentID { - return fmt.Errorf("department cannot be its own parent") - } - - // Check if new parent is a descendant - descendants, err := p.GetAllDescendants(ctx, departmentID) - if err != nil { - return fmt.Errorf("failed to check descendants: %w", err) - } - - for _, desc := range descendants { - if desc.ID == newParentID { - return fmt.Errorf("cannot set a descendant as parent (circular reference)") - } - } - - return nil -} - -// GetDepartmentWithDescendants gets a department and all its descendants -func (p *DepartmentProcessorImpl) GetDepartmentWithDescendants(ctx context.Context, departmentID uuid.UUID) ([]entities.Department, error) { - // Get the department - dept, err := p.departmentRepo.Get(ctx, departmentID) - if err != nil { - return nil, err - } - - departments := []entities.Department{*dept} - - // Get all descendants - descendants, err := p.departmentRepo.GetAllDescendants(ctx, departmentID) - if err == nil { - departments = append(departments, descendants...) - } - - return departments, nil -} - -// GetDepartmentWithParentAndSiblings gets a department with its parent and all siblings -func (p *DepartmentProcessorImpl) GetDepartmentWithParentAndSiblings(ctx context.Context, departmentID uuid.UUID) ([]entities.Department, error) { - // Get the department - dept, err := p.departmentRepo.Get(ctx, departmentID) - if err != nil { - return nil, err - } - - var departments []entities.Department - - // If has parent, get parent and all its descendants - if dept.ParentDepartmentID != nil { - parent, err := p.departmentRepo.Get(ctx, *dept.ParentDepartmentID) - if err == nil { - departments = append(departments, *parent) - - // Get all descendants of parent (includes siblings) - descendants, err := p.departmentRepo.GetAllDescendants(ctx, parent.ID) - if err == nil { - departments = append(departments, descendants...) - } - } else { - // Fallback to just department and its descendants - return p.GetDepartmentWithDescendants(ctx, departmentID) - } - } else { - // No parent, just get department and descendants - return p.GetDepartmentWithDescendants(ctx, departmentID) - } - - return departments, nil -} - -// GetAllDepartments gets all departments in the system -func (p *DepartmentProcessorImpl) GetAllDepartments(ctx context.Context) ([]entities.Department, error) { - departments, _, err := p.departmentRepo.List(ctx, "", 0, 0) - return departments, err -} - -// GetDepartmentsByRootPath gets departments starting from a specific path -func (p *DepartmentProcessorImpl) GetDepartmentsByRootPath(ctx context.Context, rootPath string) ([]entities.Department, error) { - // Find the root department - rootDept, err := p.departmentRepo.GetByPath(ctx, rootPath) - if err != nil { - return nil, err - } - - return p.GetDepartmentWithDescendants(ctx, rootDept.ID) -} - -// updateChildrenPathsRecursively updates paths for all children recursively -func (p *DepartmentProcessorImpl) updateChildrenPathsRecursively(ctx context.Context, parentID uuid.UUID, parentPath string) error { - children, err := p.departmentRepo.GetChildren(ctx, parentPath) - if err != nil { - return err - } - - for _, child := range children { - // Update child's path - child.Path = parentPath + "." + child.Code - if err := p.departmentRepo.Update(ctx, &child); err != nil { - return err - } - - // Recursively update grandchildren - if err := p.updateChildrenPathsRecursively(ctx, child.ID, child.Path); err != nil { - return err - } - } - - return nil -} \ No newline at end of file diff --git a/internal/processor/letter_activity_processor.go b/internal/processor/letter_activity_processor.go deleted file mode 100644 index 714e74a..0000000 --- a/internal/processor/letter_activity_processor.go +++ /dev/null @@ -1,52 +0,0 @@ -package processor - -import ( - "context" - "time" - - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" - - "github.com/google/uuid" -) - -type LetterActivityProcessor interface { - LogActivity(ctx context.Context, letterID uuid.UUID, action string, userID uuid.UUID, details map[string]interface{}) error - LogStatusChange(ctx context.Context, letterID uuid.UUID, fromStatus, toStatus string, userID uuid.UUID) error - GetActivitiesByLetterID(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingActivityLog, error) -} - -type LetterActivityProcessorImpl struct { - activityLogRepo *repository.LetterOutgoingActivityLogRepository -} - -func NewLetterActivityProcessor(activityLogRepo *repository.LetterOutgoingActivityLogRepository) *LetterActivityProcessorImpl { - return &LetterActivityProcessorImpl{ - activityLogRepo: activityLogRepo, - } -} - -func (p *LetterActivityProcessorImpl) LogActivity(ctx context.Context, letterID uuid.UUID, action string, userID uuid.UUID, details map[string]interface{}) error { - log := &entities.LetterOutgoingActivityLog{ - LetterID: letterID, - ActionType: action, - ActorUserID: &userID, - Context: details, - OccurredAt: time.Now(), - } - - return p.activityLogRepo.Create(ctx, log) -} - -func (p *LetterActivityProcessorImpl) LogStatusChange(ctx context.Context, letterID uuid.UUID, fromStatus, toStatus string, userID uuid.UUID) error { - details := map[string]interface{}{ - "from_status": fromStatus, - "to_status": toStatus, - } - - return p.LogActivity(ctx, letterID, "status_changed", userID, details) -} - -func (p *LetterActivityProcessorImpl) GetActivitiesByLetterID(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingActivityLog, error) { - return p.activityLogRepo.ListByLetter(ctx, letterID) -} \ No newline at end of file diff --git a/internal/processor/letter_approval_processor.go b/internal/processor/letter_approval_processor.go deleted file mode 100644 index 241a1a3..0000000 --- a/internal/processor/letter_approval_processor.go +++ /dev/null @@ -1,113 +0,0 @@ -package processor - -import ( - "context" - "time" - - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" - - "github.com/google/uuid" -) - -type LetterApprovalProcessor interface { - CreateApprovalSteps(ctx context.Context, letter *entities.LetterOutgoing) error - ProcessApprovalAction(ctx context.Context, letterID uuid.UUID, approval *entities.LetterOutgoingApproval, userID uuid.UUID, isApproval bool) error - UpdateApprovalStatus(ctx context.Context, approval *entities.LetterOutgoingApproval, status entities.ApprovalStatus, userID uuid.UUID) error - CheckAllApprovalsComplete(ctx context.Context, letterID uuid.UUID) (bool, error) -} - -type LetterApprovalProcessorImpl struct { - approvalRepo *repository.LetterOutgoingApprovalRepository - approvalFlowRepo *repository.ApprovalFlowRepository - letterRepo *repository.LetterOutgoingRepository -} - -func NewLetterApprovalProcessor( - approvalRepo *repository.LetterOutgoingApprovalRepository, - approvalFlowRepo *repository.ApprovalFlowRepository, - letterRepo *repository.LetterOutgoingRepository, -) *LetterApprovalProcessorImpl { - return &LetterApprovalProcessorImpl{ - approvalRepo: approvalRepo, - approvalFlowRepo: approvalFlowRepo, - letterRepo: letterRepo, - } -} - -func (p *LetterApprovalProcessorImpl) CreateApprovalSteps(ctx context.Context, letter *entities.LetterOutgoing) error { - if letter.ApprovalFlowID == nil { - return nil - } - - flow, err := p.approvalFlowRepo.Get(ctx, *letter.ApprovalFlowID) - if err != nil || flow == nil || len(flow.Steps) == 0 { - return err - } - - // Find the minimum parallel group - minParallelGroup := flow.Steps[0].ParallelGroup - for _, step := range flow.Steps { - if step.ParallelGroup < minParallelGroup { - minParallelGroup = step.ParallelGroup - } - } - - // Create approval records for each step - for _, step := range flow.Steps { - approval := entities.LetterOutgoingApproval{ - LetterID: letter.ID, - StepID: step.ID, - StepOrder: step.StepOrder, - ParallelGroup: step.ParallelGroup, - IsRequired: step.Required, - ApproverID: step.ApproverUserID, - } - - // Set initial status - all approvals in first parallel group are pending - if step.ParallelGroup == minParallelGroup { - approval.Status = entities.ApprovalStatusPending - } else { - approval.Status = entities.ApprovalStatusNotStarted - } - - if err := p.approvalRepo.Create(ctx, &approval); err != nil { - return err - } - } - - return nil -} - -func (p *LetterApprovalProcessorImpl) ProcessApprovalAction(ctx context.Context, letterID uuid.UUID, approval *entities.LetterOutgoingApproval, userID uuid.UUID, isApproval bool) error { - status := entities.ApprovalStatusApproved - if !isApproval { - status = entities.ApprovalStatusRejected - } - - return p.UpdateApprovalStatus(ctx, approval, status, userID) -} - -func (p *LetterApprovalProcessorImpl) UpdateApprovalStatus(ctx context.Context, approval *entities.LetterOutgoingApproval, status entities.ApprovalStatus, userID uuid.UUID) error { - approval.Status = status - approval.ApproverID = &userID - now := time.Now() - approval.ActedAt = &now - - return p.approvalRepo.Update(ctx, approval) -} - -func (p *LetterApprovalProcessorImpl) CheckAllApprovalsComplete(ctx context.Context, letterID uuid.UUID) (bool, error) { - approvals, err := p.approvalRepo.ListByLetter(ctx, letterID) - if err != nil { - return false, err - } - - for _, approval := range approvals { - if approval.IsRequired && approval.Status != entities.ApprovalStatusApproved { - return false, nil - } - } - - return true, nil -} \ No newline at end of file diff --git a/internal/processor/letter_attachment_processor.go b/internal/processor/letter_attachment_processor.go deleted file mode 100644 index 3ec7ead..0000000 --- a/internal/processor/letter_attachment_processor.go +++ /dev/null @@ -1,54 +0,0 @@ -package processor - -import ( - "context" - - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" - - "github.com/google/uuid" -) - -type LetterAttachmentProcessor interface { - CreateAttachments(ctx context.Context, letterID uuid.UUID, attachments []entities.LetterOutgoingAttachment) error - RemoveAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error - GetAttachmentsByLetterID(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingAttachment, error) -} - -type LetterAttachmentProcessorImpl struct { - attachmentRepo *repository.LetterOutgoingAttachmentRepository -} - -func NewLetterAttachmentProcessor(attachmentRepo *repository.LetterOutgoingAttachmentRepository) *LetterAttachmentProcessorImpl { - return &LetterAttachmentProcessorImpl{ - attachmentRepo: attachmentRepo, - } -} - -func (p *LetterAttachmentProcessorImpl) CreateAttachments(ctx context.Context, letterID uuid.UUID, attachments []entities.LetterOutgoingAttachment) error { - if len(attachments) == 0 { - return nil - } - - // Set letter ID for all attachments - for i := range attachments { - attachments[i].LetterID = letterID - } - - return p.attachmentRepo.CreateBulk(ctx, attachments) -} - -func (p *LetterAttachmentProcessorImpl) RemoveAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error { - return p.attachmentRepo.Delete(ctx, attachmentID) -} - -func (p *LetterAttachmentProcessorImpl) GetAttachmentsByLetterID(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingAttachment, error) { - attachmentMap, err := p.attachmentRepo.ListByLetterIDs(ctx, []uuid.UUID{letterID}) - if err != nil { - return nil, err - } - if attachments, ok := attachmentMap[letterID]; ok { - return attachments, nil - } - return []entities.LetterOutgoingAttachment{}, nil -} \ No newline at end of file diff --git a/internal/processor/letter_creation_processor.go b/internal/processor/letter_creation_processor.go deleted file mode 100644 index 419a125..0000000 --- a/internal/processor/letter_creation_processor.go +++ /dev/null @@ -1,72 +0,0 @@ -package processor - -import ( - "context" - - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" - - "github.com/google/uuid" -) - -type LetterCreationProcessor interface { - PrepareLetterForCreation(ctx context.Context, letter *entities.LetterOutgoing, departmentID uuid.UUID) error - CreateLetter(ctx context.Context, letter *entities.LetterOutgoing) error - GenerateLetterNumber(ctx context.Context, letter *entities.LetterOutgoing) error -} - -type LetterCreationProcessorImpl struct { - letterRepo *repository.LetterOutgoingRepository - approvalFlowRepo *repository.ApprovalFlowRepository - numberGenerator LetterNumberGenerator -} - -func NewLetterCreationProcessor( - letterRepo *repository.LetterOutgoingRepository, - approvalFlowRepo *repository.ApprovalFlowRepository, - numberGenerator LetterNumberGenerator, -) *LetterCreationProcessorImpl { - return &LetterCreationProcessorImpl{ - letterRepo: letterRepo, - approvalFlowRepo: approvalFlowRepo, - numberGenerator: numberGenerator, - } -} - -func (p *LetterCreationProcessorImpl) PrepareLetterForCreation(ctx context.Context, letter *entities.LetterOutgoing, departmentID uuid.UUID) error { - // Assign approval flow from department if not provided - if letter.ApprovalFlowID == nil && departmentID != uuid.Nil { - flow, err := p.approvalFlowRepo.GetByDepartment(ctx, departmentID) - if err == nil && flow != nil { - letter.ApprovalFlowID = &flow.ID - } - } - - // Set initial status based on approval flow - if letter.ApprovalFlowID != nil { - letter.Status = entities.LetterOutgoingStatusPendingApproval - } else { - letter.Status = entities.LetterOutgoingStatusApproved - } - - return nil -} - -func (p *LetterCreationProcessorImpl) CreateLetter(ctx context.Context, letter *entities.LetterOutgoing) error { - return p.letterRepo.Create(ctx, letter) -} - -func (p *LetterCreationProcessorImpl) GenerateLetterNumber(ctx context.Context, letter *entities.LetterOutgoing) error { - letterNumber, err := p.numberGenerator.GenerateNumber( - ctx, - "outgoing_letter_prefix", - "outgoing_letter_sequence", - "ESLO", - ) - if err != nil { - return err - } - - letter.LetterNumber = letterNumber - return nil -} \ No newline at end of file diff --git a/internal/processor/letter_disposition_department_processor.go b/internal/processor/letter_disposition_department_processor.go deleted file mode 100644 index 96013e4..0000000 --- a/internal/processor/letter_disposition_department_processor.go +++ /dev/null @@ -1,180 +0,0 @@ -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 deleted file mode 100644 index d34eb9c..0000000 --- a/internal/processor/letter_disposition_processor.go +++ /dev/null @@ -1,240 +0,0 @@ -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_number_generator.go b/internal/processor/letter_number_generator.go deleted file mode 100644 index 5e772c9..0000000 --- a/internal/processor/letter_number_generator.go +++ /dev/null @@ -1,71 +0,0 @@ -package processor - -import ( - "context" - "fmt" - - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" -) - -// LetterNumberGenerator handles generation of letter numbers with configurable prefix and sequence -type LetterNumberGenerator interface { - GenerateNumber(ctx context.Context, prefixKey, sequenceKey string, defaultPrefix string) (string, error) -} - -type LetterNumberGeneratorImpl struct { - settingRepo *repository.AppSettingRepository -} - -func NewLetterNumberGenerator(settingRepo *repository.AppSettingRepository) *LetterNumberGeneratorImpl { - return &LetterNumberGeneratorImpl{ - settingRepo: settingRepo, - } -} - -func (g *LetterNumberGeneratorImpl) GenerateNumber(ctx context.Context, prefixKey, sequenceKey string, defaultPrefix string) (string, error) { - prefix := defaultPrefix - if s, err := g.settingRepo.Get(ctx, prefixKey); err == nil { - if v, ok := s.Value["value"].(string); ok && v != "" { - prefix = v - } - } - - seq := 0 - if s, err := g.settingRepo.Get(ctx, sequenceKey); err == nil { - if v, ok := s.Value["value"].(float64); ok { - seq = int(v) - } - } - - seq = seq + 1 - - letterNumber := fmt.Sprintf("%s%04d", prefix, seq) - - if err := g.settingRepo.Upsert(ctx, sequenceKey, entities.JSONB{"value": seq}); err != nil { - } - - return letterNumber, nil -} - -// GetCurrentSequence returns the current sequence number for a given key -func (g *LetterNumberGeneratorImpl) GetCurrentSequence(ctx context.Context, sequenceKey string) (int, error) { - seq := 0 - if s, err := g.settingRepo.Get(ctx, sequenceKey); err == nil { - if v, ok := s.Value["value"].(float64); ok { - seq = int(v) - } - } - return seq, nil -} - -// GetPrefix returns the configured prefix for a given key -func (g *LetterNumberGeneratorImpl) GetPrefix(ctx context.Context, prefixKey string, defaultPrefix string) string { - prefix := defaultPrefix - if s, err := g.settingRepo.Get(ctx, prefixKey); err == nil { - if v, ok := s.Value["value"].(string); ok && v != "" { - prefix = v - } - } - return prefix -} diff --git a/internal/processor/letter_outgoing_processor.go b/internal/processor/letter_outgoing_processor.go deleted file mode 100644 index e6ffffd..0000000 --- a/internal/processor/letter_outgoing_processor.go +++ /dev/null @@ -1,1368 +0,0 @@ -package processor - -import ( - "context" - "eslogad-be/internal/appcontext" - "fmt" - "sort" - "time" - - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type LetterOutgoingProcessor interface { - CreateOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing, attachments []entities.LetterOutgoingAttachment, userID, departmentID uuid.UUID) error - GetOutgoingLetterByID(ctx context.Context, id uuid.UUID) (*entities.LetterOutgoing, error) - GetOutgoingLetterByReferenceNumber(ctx context.Context, referenceNumber *string) (*entities.LetterOutgoing, error) - ListOutgoingLetters(ctx context.Context, filter repository.ListOutgoingLettersFilter, limit, offset int) ([]entities.LetterOutgoing, int64, error) - SearchOutgoingLetters(ctx context.Context, filters map[string]interface{}, page, limit int, sortBy, sortOrder string) ([]entities.LetterOutgoing, int64, error) - UpdateOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing, userID uuid.UUID) error - DeleteOutgoingLetter(ctx context.Context, id uuid.UUID, userID uuid.UUID) error - BulkDeleteOutgoingLetters(ctx context.Context, ids []uuid.UUID, userID uuid.UUID) error - - UpdateLetterStatus(ctx context.Context, letterID uuid.UUID, status entities.LetterOutgoingStatus, userID uuid.UUID, fromStatus, toStatus *string) error - ArchiveOutgoingLetter(ctx context.Context, letterID uuid.UUID, userID uuid.UUID) error - - ProcessApprovalSubmission(ctx context.Context, letterID uuid.UUID, approvalFlowID uuid.UUID, userID uuid.UUID) error - ProcessApproval(ctx context.Context, letterID uuid.UUID, approval *entities.LetterOutgoingApproval, userID uuid.UUID) error - ProcessRejection(ctx context.Context, letterID uuid.UUID, approval *entities.LetterOutgoingApproval, userID uuid.UUID) error - ProcessRevision(ctx context.Context, letterID uuid.UUID, attachment entities.LetterOutgoingAttachment, userID uuid.UUID) error - - AddRecipients(ctx context.Context, letterID uuid.UUID, recipients []entities.LetterOutgoingRecipient, userID uuid.UUID) error - UpdateRecipient(ctx context.Context, recipient *entities.LetterOutgoingRecipient) error - RemoveRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID, userID uuid.UUID) error - - AddAttachments(ctx context.Context, letterID uuid.UUID, attachments []entities.LetterOutgoingAttachment, userID uuid.UUID) error - RemoveAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID, userID uuid.UUID) error - - AddFinalAttachments(ctx context.Context, letterID uuid.UUID, attachments []entities.LetterOutgoingFinalAttachment, userID uuid.UUID) error - RemoveFinalAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID, userID uuid.UUID) error - - CreateDiscussion(ctx context.Context, discussion *entities.LetterOutgoingDiscussion, attachments []entities.LetterOutgoingDiscussionAttachment, userID uuid.UUID) error - GetDiscussionByID(ctx context.Context, id uuid.UUID) (*entities.LetterOutgoingDiscussion, error) - UpdateDiscussion(ctx context.Context, discussion *entities.LetterOutgoingDiscussion) error - DeleteDiscussion(ctx context.Context, id uuid.UUID) error - - GetApprovalsByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingApproval, error) - GetAllApprovalsByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingApproval, error) - GetApprovalsByLetterAndRevision(ctx context.Context, letterID uuid.UUID, revisionNumber int) ([]entities.LetterOutgoingApproval, error) - GetApprovalFlow(ctx context.Context, flowID uuid.UUID) (*entities.ApprovalFlow, error) - - // 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) - BulkArchiveIncomingLettersForUser(ctx context.Context, letterIDs []uuid.UUID, userId 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) - GetBatchOutgoingRecipientsByUser(ctx context.Context, letterIDs []uuid.UUID, userID uuid.UUID) (map[uuid.UUID]*entities.LetterOutgoingRecipient, error) -} - -type LetterOutgoingProcessorImpl struct { - db *gorm.DB - letterRepo *repository.LetterOutgoingRepository - attachmentRepo *repository.LetterOutgoingAttachmentRepository - finalAttachmentRepo *repository.LetterOutgoingFinalAttachmentRepository - recipientRepo *repository.LetterOutgoingRecipientRepository - discussionRepo *repository.LetterOutgoingDiscussionRepository - discussionAttachmentRepo *repository.LetterOutgoingDiscussionAttachmentRepository - activityLogRepo *repository.LetterOutgoingActivityLogRepository - approvalFlowRepo *repository.ApprovalFlowRepository - approvalRepo *repository.LetterOutgoingApprovalRepository - numberGenerator *LetterNumberGeneratorImpl - txManager *repository.TxManager - priorityRepo *repository.PriorityRepository - institutionRepo *repository.InstitutionRepository -} - -func NewLetterOutgoingProcessor( - db *gorm.DB, - letterRepo *repository.LetterOutgoingRepository, - attachmentRepo *repository.LetterOutgoingAttachmentRepository, - finalAttachmentRepo *repository.LetterOutgoingFinalAttachmentRepository, - recipientRepo *repository.LetterOutgoingRecipientRepository, - discussionRepo *repository.LetterOutgoingDiscussionRepository, - discussionAttachmentRepo *repository.LetterOutgoingDiscussionAttachmentRepository, - activityLogRepo *repository.LetterOutgoingActivityLogRepository, - approvalFlowRepo *repository.ApprovalFlowRepository, - approvalRepo *repository.LetterOutgoingApprovalRepository, - numberGenerator *LetterNumberGeneratorImpl, - txManager *repository.TxManager, - priorityRepo *repository.PriorityRepository, - institutionRepo *repository.InstitutionRepository, -) *LetterOutgoingProcessorImpl { - return &LetterOutgoingProcessorImpl{ - db: db, - letterRepo: letterRepo, - attachmentRepo: attachmentRepo, - finalAttachmentRepo: finalAttachmentRepo, - recipientRepo: recipientRepo, - discussionRepo: discussionRepo, - discussionAttachmentRepo: discussionAttachmentRepo, - activityLogRepo: activityLogRepo, - approvalFlowRepo: approvalFlowRepo, - approvalRepo: approvalRepo, - numberGenerator: numberGenerator, - txManager: txManager, - priorityRepo: priorityRepo, - institutionRepo: institutionRepo, - } -} - -func (p *LetterOutgoingProcessorImpl) CreateOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing, attachments []entities.LetterOutgoingAttachment, userID, departmentID uuid.UUID) error { - existingOutgoing, err := p.letterRepo.GetByReferenceNumber(ctx, letter.ReferenceNumber) - if err == nil && existingOutgoing != nil { - return fmt.Errorf("surat dengan nomor %s sudah ada", *letter.ReferenceNumber) - } - - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - // Step 1: Assign approval flow from department if not provided - if err := p.assignApprovalFlowFromDepartment(txCtx, letter, departmentID); err != nil { - // Log error but continue - approval flow is optional - } - - // Step 2: Set status based on approval flow - if letter.ApprovalFlowID != nil { - letter.Status = entities.LetterOutgoingStatusPendingApproval - } else { - letter.Status = entities.LetterOutgoingStatusApproved - } - - // Step 3: Generate and assign letter number - if err := p.assignLetterNumber(txCtx, letter); err != nil { - return err - } - - // Step 4: Create the letter - if err := p.letterRepo.Create(txCtx, letter); err != nil { - return err - } - - // Step 5: Create recipients from approval flow - if err := p.createRecipientsFromApprovalFlow(txCtx, letter); err != nil { - return err - } - - // Step 6: Create attachments - if err := p.createAttachments(txCtx, letter.ID, attachments); err != nil { - return err - } - - // Step 7: Log the activity - return p.logActivity(txCtx, letter.ID, entities.LetterOutgoingActionCreated, userID) - }) -} - -func (p *LetterOutgoingProcessorImpl) GetOutgoingLetterByReferenceNumber(ctx context.Context, referenceNumber *string) (*entities.LetterOutgoing, error) { - return p.letterRepo.GetByReferenceNumber(ctx, referenceNumber) -} - -func (p *LetterOutgoingProcessorImpl) assignApprovalFlowFromDepartment(ctx context.Context, letter *entities.LetterOutgoing, departmentID uuid.UUID) error { - if letter.ApprovalFlowID != nil || departmentID == uuid.Nil { - return nil - } - - flow, err := p.approvalFlowRepo.GetByDepartment(ctx, departmentID) - if err != nil { - return err - } - - if flow != nil { - letter.ApprovalFlowID = &flow.ID - } - - return nil -} - -func (p *LetterOutgoingProcessorImpl) assignLetterNumber(ctx context.Context, letter *entities.LetterOutgoing) error { - letterNumber, err := p.numberGenerator.GenerateNumber( - ctx, - contract.SettingOutgoingLetterPrefix, - contract.SettingOutgoingLetterSequence, - "ESLO", - ) - - if err != nil { - return err - } - - letter.LetterNumber = letterNumber - - return nil -} - -func (p *LetterOutgoingProcessorImpl) createRecipientsFromApprovalFlow(ctx context.Context, letter *entities.LetterOutgoing) error { - if letter.ApprovalFlowID == nil { - return nil - } - - flow, err := p.approvalFlowRepo.Get(ctx, *letter.ApprovalFlowID) - if err != nil || flow == nil || len(flow.Steps) == 0 { - return err - } - - // Find the minimum step order (first step) - minStepOrder := flow.Steps[0].StepOrder - for _, step := range flow.Steps { - if step.StepOrder < minStepOrder { - minStepOrder = step.StepOrder - } - } - - // Create all approval steps in letter_outgoing_approvals - var approvals []entities.LetterOutgoingApproval - for _, step := range flow.Steps { - approval := entities.LetterOutgoingApproval{ - LetterID: letter.ID, - StepID: step.ID, - StepOrder: step.StepOrder, - ParallelGroup: step.ParallelGroup, - IsRequired: step.Required, - ApproverID: step.ApproverUserID, - } - - // Set status based on step order - if step.StepOrder == minStepOrder { - // First step(s) are set to pending - approval.Status = entities.ApprovalStatusPending - } else { - // All other steps are set to not_started - approval.Status = entities.ApprovalStatusNotStarted - } - - approvals = append(approvals, approval) - } - - // Bulk create all approvals - if len(approvals) > 0 { - if err := p.approvalRepo.CreateBulk(ctx, approvals); err != nil { - return err - } - } - - // Also create recipients from the first step (for backward compatibility) - var recipients []entities.LetterOutgoingRecipient - for i, step := range flow.Steps { - // Only process steps with the minimum step order (first step) - if step.StepOrder != minStepOrder { - continue - } - - recipient := p.createRecipientFromApprovalStep(step, letter.ID) - if recipient != nil { - // Mark the first recipient as primary - if i == 0 { - recipient.IsPrimary = true - } else { - recipient.IsPrimary = false - } - recipients = append(recipients, *recipient) - } - } - - // Bulk create all recipients if any - if len(recipients) > 0 { - return p.recipientRepo.CreateBulk(ctx, recipients) - } - - return nil -} - -// createRecipientFromApprovalStep creates a recipient from an approval flow step -func (p *LetterOutgoingProcessorImpl) createRecipientFromApprovalStep(step entities.ApprovalFlowStep, letterID uuid.UUID) *entities.LetterOutgoingRecipient { - recipient := &entities.LetterOutgoingRecipient{ - LetterID: letterID, - Status: "pending", - IsArchived: false, - UserID: &step.ApproverUser.ID, - } - - return recipient -} - -// createAttachments creates letter attachments -func (p *LetterOutgoingProcessorImpl) createAttachments(ctx context.Context, letterID uuid.UUID, attachments []entities.LetterOutgoingAttachment) error { - if len(attachments) == 0 { - return nil - } - - // Update letter IDs for all attachments - for i := range attachments { - attachments[i].LetterID = letterID - } - - return p.attachmentRepo.CreateBulk(ctx, attachments) -} - -// logActivity logs an activity for the letter -func (p *LetterOutgoingProcessorImpl) logActivity(ctx context.Context, letterID uuid.UUID, actionType string, userID uuid.UUID) error { - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: letterID, - ActionType: actionType, - ActorUserID: &userID, - } - return p.activityLogRepo.Create(ctx, activityLog) -} - -func (p *LetterOutgoingProcessorImpl) GetOutgoingLetterByID(ctx context.Context, id uuid.UUID) (*entities.LetterOutgoing, error) { - return p.letterRepo.Get(ctx, id) -} - -func (p *LetterOutgoingProcessorImpl) ListOutgoingLetters(ctx context.Context, filter repository.ListOutgoingLettersFilter, limit, offset int) ([]entities.LetterOutgoing, int64, error) { - appCtx := appcontext.FromGinContext(ctx) - - fmt.Printf("Checked User Role: %s\n", appCtx.UserRole) - fmt.Printf("Checked User ID: %s\n", appCtx.UserID) - - if appCtx.IsSuperAdmin() { - fmt.Println("Checked Role: super admin") - return p.letterRepo.ListAll(ctx, filter, limit, offset) - } - - return p.letterRepo.List(ctx, filter, limit, offset) -} - -func (p *LetterOutgoingProcessorImpl) UpdateOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing, userID uuid.UUID) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - if err := p.letterRepo.Update(txCtx, letter); err != nil { - return err - } - - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: letter.ID, - ActionType: entities.LetterOutgoingActionUpdated, - ActorUserID: &userID, - } - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - return err - } - - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) DeleteOutgoingLetter(ctx context.Context, id uuid.UUID, userID uuid.UUID) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - if err := p.letterRepo.SoftDelete(txCtx, id); err != nil { - return err - } - - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: id, - ActionType: entities.LetterOutgoingActionDeleted, - ActorUserID: &userID, - } - - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - return err - } - - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) BulkDeleteOutgoingLetters(ctx context.Context, ids []uuid.UUID, userID uuid.UUID) error { - if len(ids) == 0 { - return nil - } - - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - - // Create activity log untuk setiap letter yang dihapus - for _, id := range ids { - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: id, - ActionType: entities.LetterOutgoingActionDeleted, - ActorUserID: &userID, - } - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - return err - } - } - - if err := p.letterRepo.BulkSoftDelete(txCtx, ids); err != nil { - return err - } - - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) UpdateLetterStatus(ctx context.Context, letterID uuid.UUID, status entities.LetterOutgoingStatus, userID uuid.UUID, fromStatus, toStatus *string) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - if err := p.letterRepo.UpdateStatus(txCtx, letterID, status); err != nil { - return err - } - - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: letterID, - ActorUserID: &userID, - FromStatus: fromStatus, - ToStatus: toStatus, - } - - switch status { - case entities.LetterOutgoingStatusPendingApproval: - activityLog.ActionType = entities.LetterOutgoingActionSubmittedApproval - case entities.LetterOutgoingStatusApproved: - activityLog.ActionType = entities.LetterOutgoingActionApproved - case entities.LetterOutgoingStatusSent: - activityLog.ActionType = entities.LetterOutgoingActionSent - default: - activityLog.ActionType = entities.LetterOutgoingActionUpdated - } - - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - return err - } - - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) ArchiveOutgoingLetter(ctx context.Context, letterID uuid.UUID, userID uuid.UUID) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - // Archive the letter using the new flag - if err := p.letterRepo.Archive(txCtx, letterID); err != nil { - return err - } - - // Log the activity - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: letterID, - ActorUserID: &userID, - ActionType: entities.LetterOutgoingActionArchived, - } - - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - return err - } - - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) ProcessApprovalSubmission(ctx context.Context, letterID uuid.UUID, approvalFlowID uuid.UUID, userID uuid.UUID) error { - flow, err := p.approvalFlowRepo.Get(ctx, approvalFlowID) - if err != nil { - return err - } - - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - // Get the letter to get the current revision number - letter, err := p.letterRepo.Get(txCtx, letterID) - if err != nil { - return err - } - - // Find the minimum step order (first step) - minStepOrder := flow.Steps[0].StepOrder - for _, step := range flow.Steps { - if step.StepOrder < minStepOrder { - minStepOrder = step.StepOrder - } - } - - approvals := make([]entities.LetterOutgoingApproval, len(flow.Steps)) - for i, step := range flow.Steps { - approvals[i] = entities.LetterOutgoingApproval{ - LetterID: letterID, - StepID: step.ID, - RevisionNumber: letter.RevisionNumber, - StepOrder: step.StepOrder, - ParallelGroup: step.ParallelGroup, - IsRequired: step.Required, - ApproverID: step.ApproverUserID, - Status: entities.ApprovalStatusPending, - } - - // Set status based on step order - if step.StepOrder == minStepOrder { - approvals[i].Status = entities.ApprovalStatusPending - } else { - approvals[i].Status = entities.ApprovalStatusNotStarted - } - } - - if err := p.approvalRepo.CreateBulk(txCtx, approvals); err != nil { - return err - } - - // Add first step approvers as recipients - existingRecipients, err := p.recipientRepo.ListByLetter(txCtx, letterID) - if err != nil { - return err - } - - // Create a map of existing user IDs for quick lookup - existingUserIDs := make(map[uuid.UUID]bool) - for _, recipient := range existingRecipients { - if recipient.UserID != nil { - existingUserIDs[*recipient.UserID] = true - } - } - - // Add approvers from the first step as recipients - for _, approval := range approvals { - if approval.StepOrder == minStepOrder && approval.ApproverID != nil { - if !existingUserIDs[*approval.ApproverID] { - newRecipient := entities.LetterOutgoingRecipient{ - LetterID: letterID, - UserID: approval.ApproverID, - IsPrimary: false, - Status: "unread", - IsArchived: false, - } - if err := p.recipientRepo.Create(txCtx, &newRecipient); err != nil { - return err - } - existingUserIDs[*approval.ApproverID] = true - } - } - } - - if err := p.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusPendingApproval); err != nil { - return err - } - - fromStatus := string(entities.LetterOutgoingStatusDraft) - toStatus := string(entities.LetterOutgoingStatusPendingApproval) - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: letterID, - ActionType: entities.LetterOutgoingActionSubmittedApproval, - ActorUserID: &userID, - FromStatus: &fromStatus, - ToStatus: &toStatus, - } - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - return err - } - - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) ProcessApproval(ctx context.Context, letterID uuid.UUID, approval *entities.LetterOutgoingApproval, userID uuid.UUID) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - // Step 1: Update the approval record - if err := p.updateApprovalRecord(txCtx, approval, userID); err != nil { - return err - } - - // Step 2: Get all approvals FOR THE SAME REVISION and organize by parallel group - approvalsByGroup, err := p.getApprovalsByParallelGroupForRevision(txCtx, letterID, approval.RevisionNumber) - if err != nil { - return err - } - - // Step 3: Check if current parallel group is completed - if p.isParallelGroupCompleted(approvalsByGroup[approval.ParallelGroup]) { - // Step 4: Activate next parallel group if exists - if err := p.activateNextParallelGroupForRevision(txCtx, letterID, approval.ParallelGroup, approval.RevisionNumber, approvalsByGroup); err != nil { - return err - } - } - - // Step 5: Check if all required approvals are completed FOR THIS REVISION - if p.areAllRequiredApprovalsCompletedByGroup(approvalsByGroup) { - // Step 6: Update letter status to approved - if err := p.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusApproved); err != nil { - return err - } - } - - // Step 7: Log the activity - return p.logApprovalActivity(txCtx, letterID, approval.ID, userID) - }) -} - -// updateApprovalRecord updates the approval with approver info and timestamp -func (p *LetterOutgoingProcessorImpl) updateApprovalRecord(ctx context.Context, approval *entities.LetterOutgoingApproval, userID uuid.UUID) error { - now := time.Now() - approval.Status = entities.ApprovalStatusApproved - approval.ApproverID = &userID - approval.ActedAt = &now - - return p.approvalRepo.Update(ctx, approval) -} - -// getApprovalsByStep fetches all approvals and organizes them by step order -func (p *LetterOutgoingProcessorImpl) getApprovalsByStep(ctx context.Context, letterID uuid.UUID) (map[int][]entities.LetterOutgoingApproval, error) { - allApprovals, err := p.approvalRepo.ListByLetter(ctx, letterID) - if err != nil { - return nil, err - } - - approvalsByStep := make(map[int][]entities.LetterOutgoingApproval) - for _, approval := range allApprovals { - approvalsByStep[approval.StepOrder] = append(approvalsByStep[approval.StepOrder], approval) - } - - return approvalsByStep, nil -} - -// getApprovalsByStepForRevision fetches approvals for a specific revision and organizes them by step order -func (p *LetterOutgoingProcessorImpl) getApprovalsByStepForRevision(ctx context.Context, letterID uuid.UUID, revisionNumber int) (map[int][]entities.LetterOutgoingApproval, error) { - allApprovals, err := p.approvalRepo.ListByLetter(ctx, letterID) - if err != nil { - return nil, err - } - - approvalsByStep := make(map[int][]entities.LetterOutgoingApproval) - for _, approval := range allApprovals { - // Only include approvals from the same revision - if approval.RevisionNumber == revisionNumber { - approvalsByStep[approval.StepOrder] = append(approvalsByStep[approval.StepOrder], approval) - } - } - - return approvalsByStep, nil -} - -// getApprovalsByParallelGroupForRevision fetches approvals for a specific revision and organizes them by parallel group -func (p *LetterOutgoingProcessorImpl) getApprovalsByParallelGroupForRevision(ctx context.Context, letterID uuid.UUID, revisionNumber int) (map[int][]entities.LetterOutgoingApproval, error) { - allApprovals, err := p.approvalRepo.ListByLetter(ctx, letterID) - if err != nil { - return nil, err - } - - approvalsByGroup := make(map[int][]entities.LetterOutgoingApproval) - for _, approval := range allApprovals { - // Only include approvals from the same revision - if approval.RevisionNumber == revisionNumber { - approvalsByGroup[approval.ParallelGroup] = append(approvalsByGroup[approval.ParallelGroup], approval) - } - } - - return approvalsByGroup, nil -} - -// isStepCompleted checks if all required approvals in a step are approved -// For parallel groups, at least one approval per group must be completed -func (p *LetterOutgoingProcessorImpl) isStepCompleted(stepApprovals []entities.LetterOutgoingApproval) bool { - // Group approvals by parallel group - approvalsByParallelGroup := make(map[int][]entities.LetterOutgoingApproval) - for _, approval := range stepApprovals { - approvalsByParallelGroup[approval.ParallelGroup] = append( - approvalsByParallelGroup[approval.ParallelGroup], - approval, - ) - } - - // Check each parallel group - for _, groupApprovals := range approvalsByParallelGroup { - // For each parallel group, check if it has at least one approved - groupHasApproval := false - hasRequiredPending := false - - for _, approval := range groupApprovals { - if approval.Status == entities.ApprovalStatusApproved { - groupHasApproval = true - } - if approval.IsRequired && approval.Status != entities.ApprovalStatusApproved { - hasRequiredPending = true - } - } - - // If this group has required approvals that are still pending, step is not complete - if hasRequiredPending { - return false - } - - // If this group has no approvals at all and contains required approvals, step is not complete - if !groupHasApproval { - for _, approval := range groupApprovals { - if approval.IsRequired { - return false - } - } - } - } - - return true -} - -// activateNextStep activates the next approval step and adds approvers as recipients -func (p *LetterOutgoingProcessorImpl) activateNextStep(ctx context.Context, letterID uuid.UUID, currentStepOrder int, approvalsByStep map[int][]entities.LetterOutgoingApproval) error { - nextStepOrder := currentStepOrder + 1 - nextStepApprovals, exists := approvalsByStep[nextStepOrder] - if !exists { - return nil // No next step - } - - // Get existing recipients to avoid duplicates - existingUserIDs, err := p.getExistingRecipientUserIDs(ctx, letterID) - if err != nil { - return err - } - - // Process each approval in the next step - for _, nextApproval := range nextStepApprovals { - // Activate approval if not started - if err := p.activateApprovalIfNotStarted(ctx, &nextApproval); err != nil { - return err - } - - // Add approver as recipient if not already exists - if err := p.addApproverAsRecipientIfNeeded(ctx, letterID, nextApproval.ApproverID, existingUserIDs); err != nil { - return err - } - } - - return nil -} - -// activateNextStepForRevision activates the next approval step for a specific revision -func (p *LetterOutgoingProcessorImpl) activateNextStepForRevision(ctx context.Context, letterID uuid.UUID, currentStepOrder int, revisionNumber int, approvalsByStep map[int][]entities.LetterOutgoingApproval) error { - nextStepOrder := currentStepOrder + 1 - nextStepApprovals, exists := approvalsByStep[nextStepOrder] - if !exists { - return nil // No next step - } - - // Get existing recipients to avoid duplicates - existingUserIDs, err := p.getExistingRecipientUserIDs(ctx, letterID) - if err != nil { - return err - } - - // Process each approval in the next step (already filtered by revision in approvalsByStep) - for _, nextApproval := range nextStepApprovals { - // Only process if it's the same revision - if nextApproval.RevisionNumber == revisionNumber { - // Activate approval if not started - if err := p.activateApprovalIfNotStarted(ctx, &nextApproval); err != nil { - return err - } - - // Add approver as recipient if not already exists - if err := p.addApproverAsRecipientIfNeeded(ctx, letterID, nextApproval.ApproverID, existingUserIDs); err != nil { - return err - } - } - } - - return nil -} - -// getExistingRecipientUserIDs gets a set of existing recipient user IDs -func (p *LetterOutgoingProcessorImpl) getExistingRecipientUserIDs(ctx context.Context, letterID uuid.UUID) (map[uuid.UUID]bool, error) { - currentRecipients, err := p.recipientRepo.ListByLetter(ctx, letterID) - if err != nil { - return nil, err - } - - existingUserIDs := make(map[uuid.UUID]bool) - for _, recipient := range currentRecipients { - if recipient.UserID != nil { - existingUserIDs[*recipient.UserID] = true - } - } - - return existingUserIDs, nil -} - -// activateApprovalIfNotStarted changes approval status from not_started to pending -func (p *LetterOutgoingProcessorImpl) activateApprovalIfNotStarted(ctx context.Context, approval *entities.LetterOutgoingApproval) error { - if approval.Status != entities.ApprovalStatusNotStarted { - return nil - } - - approval.Status = entities.ApprovalStatusPending - return p.approvalRepo.Update(ctx, approval) -} - -// addApproverAsRecipientIfNeeded adds an approver as a recipient if they don't exist -func (p *LetterOutgoingProcessorImpl) addApproverAsRecipientIfNeeded(ctx context.Context, letterID uuid.UUID, approverID *uuid.UUID, existingUserIDs map[uuid.UUID]bool) error { - if approverID == nil || existingUserIDs[*approverID] { - return nil - } - - newRecipient := entities.LetterOutgoingRecipient{ - LetterID: letterID, - UserID: approverID, - IsPrimary: false, - Status: "unread", - IsArchived: false, - } - - if err := p.recipientRepo.Create(ctx, &newRecipient); err != nil { - return err - } - - existingUserIDs[*approverID] = true - return nil -} - -// areAllRequiredApprovalsCompleted checks if all required approvals are completed -func (p *LetterOutgoingProcessorImpl) areAllRequiredApprovalsCompleted(approvalsByStep map[int][]entities.LetterOutgoingApproval) bool { - for _, stepApprovals := range approvalsByStep { - for _, approval := range stepApprovals { - if approval.IsRequired && approval.Status != entities.ApprovalStatusApproved { - return false - } - } - } - return true -} - -// isParallelGroupCompleted checks if all required approvals in a parallel group are approved -func (p *LetterOutgoingProcessorImpl) isParallelGroupCompleted(groupApprovals []entities.LetterOutgoingApproval) bool { - for _, approval := range groupApprovals { - if approval.IsRequired && approval.Status != entities.ApprovalStatusApproved { - return false - } - } - return true -} - -// activateNextParallelGroupForRevision activates the next parallel group for a specific revision -func (p *LetterOutgoingProcessorImpl) activateNextParallelGroupForRevision(ctx context.Context, letterID uuid.UUID, currentGroup int, revisionNumber int, approvalsByGroup map[int][]entities.LetterOutgoingApproval) error { - // Find the next parallel group (handles non-sequential group numbers) - var groups []int - for group := range approvalsByGroup { - groups = append(groups, group) - } - sort.Ints(groups) - - var nextGroup int = -1 - for _, group := range groups { - if group > currentGroup { - nextGroup = group - break - } - } - - if nextGroup == -1 { - return nil // No next group - } - - nextGroupApprovals, exists := approvalsByGroup[nextGroup] - if !exists { - return nil - } - - // Get existing recipients to avoid duplicates - existingUserIDs, err := p.getExistingRecipientUserIDs(ctx, letterID) - if err != nil { - return err - } - - // Process each approval in the next group - for _, nextApproval := range nextGroupApprovals { - // Only process if it's the same revision - if nextApproval.RevisionNumber == revisionNumber { - // Activate approval if not started - if err := p.activateApprovalIfNotStarted(ctx, &nextApproval); err != nil { - return err - } - - // Add approver as recipient if not already exists - if err := p.addApproverAsRecipientIfNeeded(ctx, letterID, nextApproval.ApproverID, existingUserIDs); err != nil { - return err - } - } - } - - return nil -} - -// areAllRequiredApprovalsCompletedByGroup checks if all required approvals are completed (organized by parallel group) -func (p *LetterOutgoingProcessorImpl) areAllRequiredApprovalsCompletedByGroup(approvalsByGroup map[int][]entities.LetterOutgoingApproval) bool { - for _, groupApprovals := range approvalsByGroup { - for _, approval := range groupApprovals { - if approval.IsRequired && approval.Status != entities.ApprovalStatusApproved { - return false - } - } - } - return true -} - -// logApprovalActivity creates an activity log for the approval action -func (p *LetterOutgoingProcessorImpl) logApprovalActivity(ctx context.Context, letterID, approvalID uuid.UUID, userID uuid.UUID) error { - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: letterID, - ActionType: entities.LetterOutgoingActionApproved, - ActorUserID: &userID, - TargetID: &approvalID, - } - return p.activityLogRepo.Create(ctx, activityLog) -} - -func (p *LetterOutgoingProcessorImpl) ProcessRejection(ctx context.Context, letterID uuid.UUID, approval *entities.LetterOutgoingApproval, userID uuid.UUID) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - now := time.Now() - approval.Status = entities.ApprovalStatusRejected - approval.ApproverID = &userID - approval.ActedAt = &now - - if err := p.approvalRepo.Update(txCtx, approval); err != nil { - return err - } - - // Mark all other pending approvals in the same revision as rejected - allApprovals, err := p.approvalRepo.ListByLetter(txCtx, letterID) - if err != nil { - return err - } - - for i := range allApprovals { - // Only update other pending approvals from the same revision - if allApprovals[i].RevisionNumber == approval.RevisionNumber && - allApprovals[i].ID != approval.ID && - allApprovals[i].Status == entities.ApprovalStatusPending { - allApprovals[i].Status = entities.ApprovalStatusRejected - if err := p.approvalRepo.Update(txCtx, &allApprovals[i]); err != nil { - return err - } - } - } - - if err := p.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusRejected); err != nil { - return err - } - - fromStatus := string(entities.LetterOutgoingStatusPendingApproval) - toStatus := string(entities.LetterOutgoingStatusRejected) - - // Include rejection remarks in activity log context - var context entities.JSONB - if approval.Remarks != nil && *approval.Remarks != "" { - context = entities.JSONB{"remarks": *approval.Remarks, "revision_number": approval.RevisionNumber} - } else { - context = entities.JSONB{"revision_number": approval.RevisionNumber} - } - - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: letterID, - ActionType: entities.LetterOutgoingActionRejected, - ActorUserID: &userID, - TargetID: &approval.ID, - FromStatus: &fromStatus, - ToStatus: &toStatus, - Context: context, - } - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - return err - } - - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) ProcessRevision(ctx context.Context, letterID uuid.UUID, attachment entities.LetterOutgoingAttachment, userID uuid.UUID) error { - fmt.Printf("[ProcessRevision] start letterID=%s userID=%s attachment=%s\n", letterID, userID, attachment.FileName) - - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - letter, err := p.letterRepo.Get(txCtx, letterID) - if err != nil { - fmt.Printf("[ProcessRevision] error get letter: %v\n", err) - return err - } - - lastRevisionNumber := letter.RevisionNumber - - fmt.Printf("[ProcessRevision] current revision=%d\n", letter.RevisionNumber) - letter.RevisionNumber++ - fmt.Printf("[ProcessRevision] incremented revision=%d\n", letter.RevisionNumber) - - attachment.RevisionNumber = letter.RevisionNumber - if err := p.attachmentRepo.Create(txCtx, &attachment); err != nil { - fmt.Printf("[ProcessRevision] error create attachment: %v\n", err) - return err - } - fmt.Println("[ProcessRevision] attachment created") - - if err := p.letterRepo.Update(txCtx, letter); err != nil { - fmt.Printf("[ProcessRevision] error update letter: %v\n", err) - return err - } - fmt.Println("[ProcessRevision] letter updated") - - if err := p.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusPendingApproval); err != nil { - fmt.Printf("[ProcessRevision] error update status: %v\n", err) - return err - } - fmt.Println("[ProcessRevision] letter status set to PENDING_APPROVAL") - - approvals, err := p.approvalRepo.ListByLetterAndLasRevisionNumber(txCtx, letterID, lastRevisionNumber) - if err != nil { - fmt.Printf("[ProcessRevision] error list approvals: %v\n", err) - return err - } - fmt.Printf("[ProcessRevision] found %d approvals\n", len(approvals)) - - for _, approval := range approvals { - newApproval := entities.LetterOutgoingApproval{ - LetterID: approval.LetterID, - StepID: approval.StepID, - RevisionNumber: letter.RevisionNumber, - StepOrder: approval.StepOrder, - ParallelGroup: approval.ParallelGroup, - IsRequired: approval.IsRequired, - Status: entities.ApprovalStatusPending, - ApproverID: approval.ApproverID, - } - if err := p.approvalRepo.Create(txCtx, &newApproval); err != nil { - fmt.Printf("[ProcessRevision] error create approval: %v\n", err) - return err - } - } - fmt.Println("[ProcessRevision] approvals cloned for new revision") - - fromStatus := string(entities.LetterOutgoingStatusRejected) - toStatus := string(entities.LetterOutgoingStatusPendingApproval) - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: letterID, - ActionType: entities.LetterOutgoingActionRevised, - ActorUserID: &userID, - TargetID: &attachment.ID, - FromStatus: &fromStatus, - ToStatus: &toStatus, - Context: entities.JSONB{"attachment": attachment.FileName, "revision_number": letter.RevisionNumber}, - } - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - fmt.Printf("[ProcessRevision] error create activity log: %v\n", err) - return err - } - - fmt.Printf("[ProcessRevision] success letterID=%s new_revision=%d\n", letterID, letter.RevisionNumber) - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) AddRecipients(ctx context.Context, letterID uuid.UUID, recipients []entities.LetterOutgoingRecipient, userID uuid.UUID) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - if err := p.recipientRepo.CreateBulk(txCtx, recipients); err != nil { - return err - } - - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: letterID, - ActionType: entities.LetterOutgoingActionRecipientAdded, - ActorUserID: &userID, - } - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - return err - } - - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) UpdateRecipient(ctx context.Context, recipient *entities.LetterOutgoingRecipient) error { - return p.recipientRepo.Update(ctx, recipient) -} - -func (p *LetterOutgoingProcessorImpl) RemoveRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID, userID uuid.UUID) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - if err := p.recipientRepo.Delete(txCtx, recipientID); err != nil { - return err - } - - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: letterID, - ActionType: entities.LetterOutgoingActionRecipientRemoved, - ActorUserID: &userID, - TargetID: &recipientID, - } - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - return err - } - - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) AddAttachments(ctx context.Context, letterID uuid.UUID, attachments []entities.LetterOutgoingAttachment, userID uuid.UUID) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - // Get the letter to get the current revision number - letter, err := p.letterRepo.Get(txCtx, letterID) - if err != nil { - return err - } - - // Set revision number on all attachments - for i := range attachments { - attachments[i].RevisionNumber = letter.RevisionNumber - } - - if err := p.attachmentRepo.CreateBulk(txCtx, attachments); err != nil { - return err - } - - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: letterID, - ActionType: entities.LetterOutgoingActionAttachmentAdded, - ActorUserID: &userID, - } - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - return err - } - - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) RemoveAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID, userID uuid.UUID) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - if err := p.attachmentRepo.Delete(txCtx, attachmentID); err != nil { - return err - } - - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: letterID, - ActionType: entities.LetterOutgoingActionAttachmentRemoved, - ActorUserID: &userID, - TargetID: &attachmentID, - } - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - return err - } - - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) AddFinalAttachments(ctx context.Context, letterID uuid.UUID, attachments []entities.LetterOutgoingFinalAttachment, userID uuid.UUID) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - // Get the letter to get the current revision number - _, err := p.letterRepo.Get(txCtx, letterID) - if err != nil { - return err - } - - if err := p.finalAttachmentRepo.CreateBulk(txCtx, attachments); err != nil { - return err - } - - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: letterID, - ActionType: entities.LetterOutgoingActionAttachmentAdded, - ActorUserID: &userID, - } - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - return err - } - - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) RemoveFinalAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID, userID uuid.UUID) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - if err := p.finalAttachmentRepo.Delete(txCtx, attachmentID); err != nil { - return err - } - - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: letterID, - ActionType: entities.LetterOutgoingActionAttachmentRemoved, - ActorUserID: &userID, - TargetID: &attachmentID, - } - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - return err - } - - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) CreateDiscussion(ctx context.Context, discussion *entities.LetterOutgoingDiscussion, attachments []entities.LetterOutgoingDiscussionAttachment, userID uuid.UUID) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - if err := p.discussionRepo.Create(txCtx, discussion); err != nil { - return err - } - - if len(attachments) > 0 { - if err := p.discussionAttachmentRepo.CreateBulk(txCtx, attachments); err != nil { - return err - } - } - - activityLog := &entities.LetterOutgoingActivityLog{ - LetterID: discussion.LetterID, - ActionType: entities.LetterOutgoingActionDiscussionAdded, - ActorUserID: &userID, - TargetID: &discussion.ID, - } - if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { - return err - } - - return nil - }) -} - -func (p *LetterOutgoingProcessorImpl) GetDiscussionByID(ctx context.Context, id uuid.UUID) (*entities.LetterOutgoingDiscussion, error) { - return p.discussionRepo.Get(ctx, id) -} - -func (p *LetterOutgoingProcessorImpl) UpdateDiscussion(ctx context.Context, discussion *entities.LetterOutgoingDiscussion) error { - return p.discussionRepo.Update(ctx, discussion) -} - -func (p *LetterOutgoingProcessorImpl) DeleteDiscussion(ctx context.Context, id uuid.UUID) error { - return p.discussionRepo.Delete(ctx, id) -} - -func (p *LetterOutgoingProcessorImpl) GetApprovalsByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingApproval, error) { - // Get the letter first to know the current revision - letter, err := p.letterRepo.Get(ctx, letterID) - if err != nil { - return nil, err - } - - return p.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) -} - -func (p *LetterOutgoingProcessorImpl) GetAllApprovalsByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingApproval, error) { - approvals, err := p.approvalRepo.ListByLetter(ctx, letterID) - if err != nil { - return nil, err - } - - return approvals, nil -} - -func (p *LetterOutgoingProcessorImpl) GetApprovalsByLetterAndRevision(ctx context.Context, letterID uuid.UUID, revisionNumber int) ([]entities.LetterOutgoingApproval, error) { - // Get all approvals for this letter - approvals, err := p.approvalRepo.ListByLetter(ctx, letterID) - if err != nil { - return nil, err - } - - // Filter to only return approvals for the specified revision - var currentRevisionApprovals []entities.LetterOutgoingApproval - for _, approval := range approvals { - if approval.RevisionNumber == revisionNumber { - currentRevisionApprovals = append(currentRevisionApprovals, approval) - } - } - - return currentRevisionApprovals, nil -} - -func (p *LetterOutgoingProcessorImpl) GetApprovalFlow(ctx context.Context, flowID uuid.UUID) (*entities.ApprovalFlow, error) { - return p.approvalFlowRepo.Get(ctx, flowID) -} - -// GetOutgoingLetterWithDetails fetches letter with all related data including approvals, discussions, and users -func (p *LetterOutgoingProcessorImpl) GetOutgoingLetterWithDetails(ctx context.Context, letterID uuid.UUID) (*entities.LetterOutgoing, error) { - letter, err := p.letterRepo.GetWithRelations(ctx, letterID, []string{ - "Priority", - "ReceiverInstitution", - "Creator", - "Creator.Profile", - "ApprovalFlow", - "ApprovalFlow.Steps", - "ApprovalFlow.Steps.ApproverRole", - "ApprovalFlow.Steps.ApproverUser", - "ApprovalFlow.Steps.ApproverUser.Profile", - "Recipients", - "Recipients.User", - "Recipients.User.Profile", - "Recipients.Department", - "Attachments", - "Approvals", - "Approvals.Step", - "Approvals.Step.ApproverRole", - "Approvals.Step.ApproverUser", - "Approvals.Step.ApproverUser.Profile", - "Approvals.Approver", - "Approvals.Approver.Profile", - "Discussions", - "Discussions.User", - "Discussions.User.Profile", - "Discussions.Attachments", - "ActivityLogs", - }) - - if err != nil { - return nil, err - } - - return letter, nil -} - -// GetUsersByIDs fetches users by their IDs -func (p *LetterOutgoingProcessorImpl) GetUsersByIDs(ctx context.Context, userIDs []uuid.UUID) ([]entities.User, error) { - if len(userIDs) == 0 { - return []entities.User{}, nil - } - - var users []entities.User - err := p.db.WithContext(ctx). - Preload("Profile"). - Where("id IN ?", userIDs). - Find(&users).Error - - if err != nil { - return nil, err - } - - return users, nil -} - -func (p *LetterOutgoingProcessorImpl) SearchOutgoingLetters(ctx context.Context, filters map[string]interface{}, page, limit int, sortBy, sortOrder string) ([]entities.LetterOutgoing, int64, error) { - offset := (page - 1) * limit - return p.letterRepo.Search(ctx, filters, limit, offset, sortBy, sortOrder) -} - -func (p *LetterOutgoingProcessorImpl) BulkArchiveOutgoingLetters(ctx context.Context, letterIDs []uuid.UUID) (int64, error) { - return p.letterRepo.BulkArchive(ctx, letterIDs) -} - -func (p *LetterOutgoingProcessorImpl) BulkArchiveIncomingLettersForUser(ctx context.Context, letterIDs []uuid.UUID, userID uuid.UUID) (int64, error) { - return p.letterRepo.BulkArchiveForUser(ctx, letterIDs, userID) -} - -// 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) -} - -func (p *LetterOutgoingProcessorImpl) GetBatchOutgoingRecipientsByUser(ctx context.Context, letterIDs []uuid.UUID, userID 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.GetByLetterIDsAndUser(ctx, letterIDs, userID) -} - -// 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_outgoing_recipient_processor.go b/internal/processor/letter_outgoing_recipient_processor.go deleted file mode 100644 index 76423cd..0000000 --- a/internal/processor/letter_outgoing_recipient_processor.go +++ /dev/null @@ -1,221 +0,0 @@ -package processor - -import ( - "context" - - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" - - "github.com/google/uuid" -) - -// LetterOutgoingRecipientProcessor handles all recipient-related operations for outgoing letters -type LetterOutgoingRecipientProcessor interface { - // CreateRecipients creates multiple recipients for a letter - CreateRecipients(ctx context.Context, letterID uuid.UUID, recipients []entities.LetterOutgoingRecipient) error - - // CreateInitialRecipients creates recipients from both approval workflow and department members - CreateInitialRecipients(ctx context.Context, letter *entities.LetterOutgoing, creatorDepartmentID uuid.UUID) error - - // UpdateRecipient updates a single recipient's information - UpdateRecipient(ctx context.Context, recipient *entities.LetterOutgoingRecipient) error - - // RemoveRecipient removes a recipient from a letter - RemoveRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID) error - - // GetRecipientsByLetterID retrieves all recipients for a specific letter - GetRecipientsByLetterID(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingRecipient, error) -} - -type LetterOutgoingRecipientProcessorImpl struct { - recipientRepo *repository.LetterOutgoingRecipientRepository - approvalFlowRepo *repository.ApprovalFlowRepository - userDeptRepo *repository.UserDepartmentRepository -} - -func NewLetterOutgoingRecipientProcessor( - recipientRepo *repository.LetterOutgoingRecipientRepository, - approvalFlowRepo *repository.ApprovalFlowRepository, - userDeptRepo *repository.UserDepartmentRepository, -) *LetterOutgoingRecipientProcessorImpl { - return &LetterOutgoingRecipientProcessorImpl{ - recipientRepo: recipientRepo, - approvalFlowRepo: approvalFlowRepo, - userDeptRepo: userDeptRepo, - } -} - -// CreateRecipients creates multiple recipients for a letter -func (p *LetterOutgoingRecipientProcessorImpl) CreateRecipients( - ctx context.Context, - letterID uuid.UUID, - recipients []entities.LetterOutgoingRecipient, -) error { - if len(recipients) == 0 { - return nil - } - - // Ensure all recipients have the correct letter ID and default status - for i := range recipients { - recipients[i].LetterID = letterID - if recipients[i].Status == "" { - recipients[i].Status = "pending" - } - } - - return p.recipientRepo.CreateBulk(ctx, recipients) -} - -// CreateInitialRecipients creates the initial set of recipients for an outgoing letter -// It combines: -// 1. Approvers from the approval workflow (if exists) -// 2. All active users from the letter creator's department -func (p *LetterOutgoingRecipientProcessorImpl) CreateInitialRecipients( - ctx context.Context, - letter *entities.LetterOutgoing, - creatorDepartmentID uuid.UUID, -) error { - // Track unique users to avoid duplicates - uniqueUsers := make(map[uuid.UUID]bool) - var allRecipients []entities.LetterOutgoingRecipient - - // Step 1: Add recipients from approval workflow - approvalRecipients := p.collectApprovalWorkflowRecipients(ctx, letter, uniqueUsers) - allRecipients = append(allRecipients, approvalRecipients...) - - // Step 2: Add all users from the creator's department - departmentRecipients := p.collectDepartmentRecipients(ctx, letter.ID, creatorDepartmentID, uniqueUsers) - allRecipients = append(allRecipients, departmentRecipients...) - - // Step 3: Mark the first recipient as primary and save all - if len(allRecipients) > 0 { - allRecipients[0].IsPrimary = true - return p.recipientRepo.CreateBulk(ctx, allRecipients) - } - - return nil -} - -// collectApprovalWorkflowRecipients gathers all users who are approvers in the workflow -func (p *LetterOutgoingRecipientProcessorImpl) collectApprovalWorkflowRecipients( - ctx context.Context, - letter *entities.LetterOutgoing, - existingUsers map[uuid.UUID]bool, -) []entities.LetterOutgoingRecipient { - var recipients []entities.LetterOutgoingRecipient - - // If no approval workflow is assigned, return empty - if letter.ApprovalFlowID == nil { - return recipients - } - - // Fetch the approval workflow - approvalFlow, err := p.approvalFlowRepo.Get(ctx, *letter.ApprovalFlowID) - if err != nil || approvalFlow == nil || len(approvalFlow.Steps) == 0 { - return recipients - } - - // Add each approver as a recipient - for _, step := range approvalFlow.Steps { - if step.ApproverUserID == nil { - continue - } - - userID := *step.ApproverUserID - - // Skip if user is already added - if existingUsers[userID] { - continue - } - - existingUsers[userID] = true - - recipient := entities.LetterOutgoingRecipient{ - LetterID: letter.ID, - UserID: &userID, - IsPrimary: false, - Status: "pending", - } - - recipients = append(recipients, recipient) - } - - return recipients -} - -// collectDepartmentRecipients gathers all active users from a specific department -func (p *LetterOutgoingRecipientProcessorImpl) collectDepartmentRecipients( - ctx context.Context, - letterID uuid.UUID, - departmentID uuid.UUID, - existingUsers map[uuid.UUID]bool, -) []entities.LetterOutgoingRecipient { - var recipients []entities.LetterOutgoingRecipient - - // If no department specified, return empty - if departmentID == uuid.Nil { - return recipients - } - - // Fetch all active users in the department - userDepartmentMappings, err := p.userDeptRepo.ListActiveByDepartmentIDs(ctx, []uuid.UUID{departmentID}) - if err != nil { - return recipients - } - - // Add each department user as a recipient - for _, mapping := range userDepartmentMappings { - // Skip if user is already added (e.g., from approval workflow) - if existingUsers[mapping.UserID] { - continue - } - - existingUsers[mapping.UserID] = true - - recipient := entities.LetterOutgoingRecipient{ - LetterID: letterID, - UserID: &mapping.UserID, - DepartmentID: &mapping.DepartmentID, - IsPrimary: false, - Status: "pending", - } - - recipients = append(recipients, recipient) - } - - return recipients -} - -// UpdateRecipient updates an existing recipient's information -func (p *LetterOutgoingRecipientProcessorImpl) UpdateRecipient( - ctx context.Context, - recipient *entities.LetterOutgoingRecipient, -) error { - return p.recipientRepo.Update(ctx, recipient) -} - -// RemoveRecipient removes a recipient from a letter -func (p *LetterOutgoingRecipientProcessorImpl) RemoveRecipient( - ctx context.Context, - letterID uuid.UUID, - recipientID uuid.UUID, -) error { - return p.recipientRepo.Delete(ctx, recipientID) -} - -// GetRecipientsByLetterID retrieves all recipients for a specific letter -func (p *LetterOutgoingRecipientProcessorImpl) GetRecipientsByLetterID( - ctx context.Context, - letterID uuid.UUID, -) ([]entities.LetterOutgoingRecipient, error) { - recipientMap, err := p.recipientRepo.ListByLetterIDs(ctx, []uuid.UUID{letterID}) - if err != nil { - return nil, err - } - - if recipients, ok := recipientMap[letterID]; ok { - return recipients, nil - } - - return []entities.LetterOutgoingRecipient{}, nil -} \ No newline at end of file diff --git a/internal/processor/letter_processor.go b/internal/processor/letter_processor.go deleted file mode 100644 index 5b16443..0000000 --- a/internal/processor/letter_processor.go +++ /dev/null @@ -1,685 +0,0 @@ -package processor - -import ( - "context" - "fmt" - "time" - - "eslogad-be/internal/appcontext" - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" - "eslogad-be/internal/transformer" - - "github.com/google/uuid" -) - -type LetterProcessorImpl struct { - letterRepo *repository.LetterIncomingRepository - attachRepo *repository.LetterIncomingAttachmentRepository - txManager *repository.TxManager - activity *ActivityLogProcessorImpl - dispositionRepo *repository.LetterIncomingDispositionRepository - dispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository - dispositionActionSelRepo *repository.LetterDispositionActionSelectionRepository - dispositionNoteRepo *repository.DispositionNoteRepository - discussionRepo *repository.LetterDiscussionRepository - settingRepo *repository.AppSettingRepository - recipientRepo *repository.LetterIncomingRecipientRepository - 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, - 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) { - userID := appcontext.FromGinContext(ctx).UserID - - existingIncoming, err := p.letterRepo.GetByReferenceNumber(ctx, req.ReferenceNumber) - if err == nil && existingIncoming != nil { - return nil, fmt.Errorf("surat dengan nomor %s sudah ada", *req.ReferenceNumber) - } - - letterType := entities.LetterIncomingTypeUtama - if req.Type == "TEMBUSAN" { - letterType = entities.LetterIncomingTypeTembusan - } - - entity := &entities.LetterIncoming{ - LetterNumber: req.LetterNumber, - ReferenceNumber: req.ReferenceNumber, - Subject: req.Subject, - Description: req.Description, - PriorityID: req.PriorityID, - SenderInstitutionID: req.SenderInstitutionID, - SenderName: req.SenderName, - Addressee: req.Addressee, - ReceivedDate: req.ReceivedDate, - DueDate: req.DueDate, - Type: letterType, - Status: entities.LetterIncomingStatusNew, - CreatedBy: userID, - } - - if err := p.letterRepo.Create(ctx, entity); err != nil { - return nil, err - } - - 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 - } - atts, _ := p.attachRepo.ListByLetter(ctx, id) - dispo, _ := p.dispositionRepo.ListByLetter(ctx, id) - var pr *entities.Priority - if entity.PriorityID != nil && p.priorityRepo != nil { - if got, err := p.priorityRepo.Get(ctx, *entity.PriorityID); err == nil { - pr = got - } - } - var inst *entities.Institution - if entity.SenderInstitutionID != nil && p.institutionRepo != nil { - if got, err := p.institutionRepo.Get(ctx, *entity.SenderInstitutionID); err == nil { - inst = got - } - } - - // 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 - fmt.Printf("Recipient: %+v\n", recipient) - } - } - - resp := transformer.LetterEntityToContract(entity, atts, dispo, 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) GetLetterUnreadCounts(ctx context.Context) (*contract.LetterUnreadCountResponse, error) { - userID := appcontext.FromGinContext(ctx).UserID - - incomingUnread := 0 - if p.recipientRepo != nil { - if count, err := p.recipientRepo.CountUnreadByUser(ctx, userID); err == nil { - incomingUnread = count - } - } - - 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 - appCtx := appcontext.FromGinContext(ctx) - - fmt.Printf("Checked User Role: %s\n", appCtx.UserRole) - fmt.Printf("Checked User ID: %s\n", appCtx.UserID) - - if appCtx.IsSuperAdmin() { - fmt.Println("Checked Role: super admin") - return p.letterRepo.ListAll(ctx, filter, page, limit) - } - - 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) GetBatchDispositions(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterIncomingDisposition, error) { - if p.dispositionRepo == nil || len(letterIDs) == 0 { - return make(map[uuid.UUID][]entities.LetterIncomingDisposition), nil - } - return p.dispositionRepo.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) { - var out *contract.IncomingLetterResponse - err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - entity, err := p.letterRepo.Get(txCtx, id) - if err != nil { - return err - } - fromStatus := string(entity.Status) - if req.ReferenceNumber != nil { - entity.ReferenceNumber = req.ReferenceNumber - } - if req.Subject != nil { - entity.Subject = *req.Subject - } - if req.Description != nil { - entity.Description = req.Description - } - if req.PriorityID != nil { - entity.PriorityID = req.PriorityID - } - if req.SenderInstitutionID != nil { - entity.SenderInstitutionID = req.SenderInstitutionID - } - if req.SenderName != nil { - entity.SenderName = req.SenderName - } - if req.Addressee != nil { - entity.Addressee = req.Addressee - } - if req.ReceivedDate != nil { - entity.ReceivedDate = *req.ReceivedDate - } - if req.DueDate != nil { - entity.DueDate = req.DueDate - } - if req.Type != nil { - entity.Type = entities.LetterIncomingType(*req.Type) - } - if req.Status != nil { - entity.Status = entities.LetterIncomingStatus(*req.Status) - } - if err := p.letterRepo.Update(txCtx, entity); err != nil { - return err - } - toStatus := string(entity.Status) - if p.activity != nil && fromStatus != toStatus { - userID := appcontext.FromGinContext(txCtx).UserID - action := "status.changed" - if err := p.activity.Log(txCtx, id, action, &userID, nil, nil, nil, &fromStatus, &toStatus, map[string]interface{}{}); err != nil { - return err - } - } - atts, _ := p.attachRepo.ListByLetter(txCtx, id) - dispo, _ := p.dispositionRepo.ListByLetter(txCtx, id) - var pr *entities.Priority - if entity.PriorityID != nil && p.priorityRepo != nil { - if got, err := p.priorityRepo.Get(txCtx, *entity.PriorityID); err == nil { - pr = got - } - } - var inst *entities.Institution - if entity.SenderInstitutionID != nil && p.institutionRepo != nil { - if got, err := p.institutionRepo.Get(txCtx, *entity.SenderInstitutionID); err == nil { - inst = got - } - } - out = transformer.LetterEntityToContract(entity, atts, dispo, pr, inst) - return nil - }) - if err != nil { - return nil, err - } - return out, nil -} - -func (p *LetterProcessorImpl) SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - if err := p.letterRepo.SoftDelete(txCtx, id); err != nil { - return err - } - if p.activity != nil { - userID := appcontext.FromGinContext(txCtx).UserID - action := "letter.deleted" - if err := p.activity.Log(txCtx, id, action, &userID, nil, nil, nil, nil, nil, map[string]interface{}{}); err != nil { - return err - } - } - return nil - }) -} - -func (p *LetterProcessorImpl) BulkSoftDeleteIncomingLetters(ctx context.Context, ids []uuid.UUID) error { - if len(ids) == 0 { - return nil - } - - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - if err := p.letterRepo.BulkSoftDelete(txCtx, ids); err != nil { - return err - } - - if p.activity != nil { - userID := appcontext.FromGinContext(txCtx).UserID - action := "letter.bulk_deleted" - - // Log activity untuk setiap letter yang dihapus - for _, id := range ids { - if err := p.activity.Log(txCtx, id, action, &userID, nil, nil, nil, nil, nil, map[string]interface{}{}); err != nil { - return err - } - } - } - - return nil - }) -} - -// CreateDispositions creates a new disposition with modular helper functions -func (p *LetterProcessorImpl) CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) { - // Transaction should be handled at service layer - // The context passed here should already contain the transaction if needed - - // Step 1: Update existing disposition departments - if err := p.updateExistingDispositionDepartments(ctx, req.LetterID, req.FromDepartment); err != nil { - return nil, err - } - - // Step 2: Create the main disposition - disp, err := p.createMainDisposition(ctx, req) - if err != nil { - return nil, err - } - - // Step 3: Create disposition departments for target departments - dispDepartments, err := p.createDispositionDepartments(ctx, disp.ID, req.LetterID, req.ToDepartmentIDs) - if err != nil { - return nil, err - } - - // Step 4: Create action selections if provided - if err := p.createActionSelections(ctx, disp.ID, req.SelectedActions, req.CreatedBy); err != nil { - return nil, err - } - - // Step 5: Build and return the response - return p.buildDispositionResponse(disp, dispDepartments, req.ToDepartmentIDs), nil -} - -// updateExistingDispositionDepartments updates the status of existing disposition departments -func (p *LetterProcessorImpl) updateExistingDispositionDepartments(ctx context.Context, letterID uuid.UUID, fromDepartment uuid.UUID) error { - existingDispDepts, err := p.dispositionDeptRepo.GetByLetterAndDepartment(ctx, letterID, fromDepartment) - if err != nil { - // If no existing departments found, that's ok - return nil - } - - for _, existingDispDept := range existingDispDepts { - if existingDispDept.Status == entities.DispositionDepartmentStatusPending { - existingDispDept.Status = entities.DispositionDepartmentStatusDispositioned - if err := p.dispositionDeptRepo.Update(ctx, &existingDispDept); err != nil { - return fmt.Errorf("failed to update existing disposition department: %w", err) - } - } - } - - return nil -} - -// createMainDisposition creates the primary disposition record -func (p *LetterProcessorImpl) createMainDisposition(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*entities.LetterIncomingDisposition, error) { - disp := &entities.LetterIncomingDisposition{ - LetterID: req.LetterID, - DepartmentID: &req.FromDepartment, - Notes: req.Notes, - CreatedBy: req.CreatedBy, // Should be set by service layer - } - - if err := p.dispositionRepo.Create(ctx, disp); err != nil { - return nil, fmt.Errorf("failed to create disposition: %w", err) - } - - return disp, nil -} - -// createDispositionDepartments creates disposition department records for target departments -func (p *LetterProcessorImpl) createDispositionDepartments(ctx context.Context, dispositionID, letterID uuid.UUID, toDepartmentIDs []uuid.UUID) ([]entities.LetterIncomingDispositionDepartment, error) { - if len(toDepartmentIDs) == 0 { - return nil, nil - } - - dispDepartments := make([]entities.LetterIncomingDispositionDepartment, 0, len(toDepartmentIDs)) - for _, toDept := range toDepartmentIDs { - dispDepartments = append(dispDepartments, entities.LetterIncomingDispositionDepartment{ - LetterIncomingDispositionID: dispositionID, - LetterIncomingID: letterID, - DepartmentID: toDept, - Status: entities.DispositionDepartmentStatusPending, - }) - } - - if err := p.dispositionDeptRepo.CreateBulk(ctx, dispDepartments); err != nil { - return nil, fmt.Errorf("failed to create disposition departments: %w", err) - } - - return dispDepartments, nil -} - -// createActionSelections creates action selection records for the disposition -func (p *LetterProcessorImpl) createActionSelections(ctx context.Context, dispositionID uuid.UUID, selectedActions []contract.CreateDispositionActionSelection, createdBy uuid.UUID) error { - if len(selectedActions) == 0 { - return nil - } - - selections := make([]entities.LetterDispositionActionSelection, 0, len(selectedActions)) - for _, sel := range selectedActions { - selections = append(selections, entities.LetterDispositionActionSelection{ - DispositionID: dispositionID, - ActionID: sel.ActionID, - Note: sel.Note, - CreatedBy: createdBy, - }) - } - - if err := p.dispositionActionSelRepo.CreateBulk(ctx, selections); err != nil { - return fmt.Errorf("failed to create action selections: %w", err) - } - - return nil -} - -// buildDispositionResponse builds the response for the created disposition -func (p *LetterProcessorImpl) buildDispositionResponse(disp *entities.LetterIncomingDisposition, dispDepartments []entities.LetterIncomingDispositionDepartment, toDepartmentIDs []uuid.UUID) *contract.ListDispositionsResponse { - response := &contract.ListDispositionsResponse{ - Dispositions: []contract.DispositionResponse{transformer.DispoToContract(*disp)}, - } - - // The toDepartmentIDs are available in the dispDepartments for service layer logging - // No need to store them in the response as DispositionResponse doesn't have this field - - return response -} - -func (p *LetterProcessorImpl) ListDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListDispositionsResponse, error) { - list, err := p.dispositionRepo.ListByLetter(ctx, letterID) - if err != nil { - return nil, err - } - return &contract.ListDispositionsResponse{Dispositions: transformer.DispositionsToContract(list)}, nil -} - -func (p *LetterProcessorImpl) GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) { - // Get dispositions with all related data preloaded in a single query - dispositions, err := p.dispositionRepo.ListByLetter(ctx, letterID) - if err != nil { - return nil, err - } - - // Get discussions with preloaded user profiles - discussions, err := p.discussionRepo.ListByLetter(ctx, letterID) - if err != nil { - return nil, err - } - - // Extract all mentioned user IDs from discussions for efficient batch fetching - var mentionedUserIDs []uuid.UUID - mentionedUserIDsMap := make(map[uuid.UUID]bool) - - for _, discussion := range discussions { - if discussion.Mentions != nil { - mentions := map[string]interface{}(discussion.Mentions) - if userIDs, ok := mentions["user_ids"]; ok { - if userIDList, ok := userIDs.([]interface{}); ok { - for _, userID := range userIDList { - if userIDStr, ok := userID.(string); ok { - if userUUID, err := uuid.Parse(userIDStr); err == nil { - if !mentionedUserIDsMap[userUUID] { - mentionedUserIDsMap[userUUID] = true - mentionedUserIDs = append(mentionedUserIDs, userUUID) - } - } - } - } - } - } - } - } - - // Fetch all mentioned users in a single batch query - var mentionedUsers []entities.User - if len(mentionedUserIDs) > 0 { - mentionedUsers, err = p.discussionRepo.GetUsersByIDs(ctx, mentionedUserIDs) - if err != nil { - return nil, err - } - } - - // Transform dispositions - enhancedDispositions := transformer.EnhancedDispositionsWithPreloadedDataToContract(dispositions) - - // Transform discussions with mentioned users - enhancedDiscussions := transformer.DiscussionsWithPreloadedDataToContract(discussions, mentionedUsers) - - return &contract.ListEnhancedDispositionsResponse{ - Dispositions: enhancedDispositions, - Discussions: enhancedDiscussions, - }, nil -} - -func (p *LetterProcessorImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) { - userID := appcontext.FromGinContext(ctx).UserID - - mentions := entities.JSONB(nil) - if req.Mentions != nil { - mentions = entities.JSONB(req.Mentions) - } - - disc := &entities.LetterDiscussion{ - ID: uuid.New(), - LetterID: letterID, - ParentID: req.ParentID, - UserID: userID, - Message: req.Message, - Mentions: mentions, - } - - if err := p.discussionRepo.Create(ctx, disc); err != nil { - return nil, fmt.Errorf("failed to create discussion: %w", err) - } - - // Activity logging should be handled at service layer - return transformer.DiscussionEntityToContract(disc), nil -} - -func (p *LetterProcessorImpl) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, string, error) { - // Transaction should be handled at service layer - disc, err := p.discussionRepo.Get(ctx, discussionID) - if err != nil { - return nil, "", fmt.Errorf("failed to get discussion: %w", err) - } - - // Store old message for activity logging - oldMessage := disc.Message - - // Update discussion fields - disc.Message = req.Message - if req.Mentions != nil { - disc.Mentions = entities.JSONB(req.Mentions) - } - now := time.Now() - disc.EditedAt = &now - - if err := p.discussionRepo.Update(ctx, disc); err != nil { - return nil, "", fmt.Errorf("failed to update discussion: %w", err) - } - - // Return both the updated discussion and old message for service layer logging - return transformer.DiscussionEntityToContract(disc), oldMessage, 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) SearchIncomingLetters(ctx context.Context, filters map[string]interface{}, page, limit int, sortBy, sortOrder string) ([]entities.LetterIncoming, int64, error) { - offset := (page - 1) * limit - return p.letterRepo.Search(ctx, filters, limit, offset, sortBy, sortOrder) -} - -func (p *LetterProcessorImpl) buildLetterResponse(ctx context.Context, entity *entities.LetterIncoming) (*contract.IncomingLetterResponse, error) { - savedAttachments, _ := p.attachRepo.ListByLetter(ctx, entity.ID) - dispo, _ := p.dispositionRepo.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, dispo, pr, inst), nil -} - -func (p *LetterProcessorImpl) BulkArchiveIncomingLetters(ctx context.Context, letterIDs []uuid.UUID) (int64, error) { - return p.letterRepo.BulkArchive(ctx, letterIDs) -} - -func (p *LetterProcessorImpl) ArchiveIncomingLetter(ctx context.Context, letterID uuid.UUID) error { - return p.letterRepo.Archive(ctx, letterID) -} - -// 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 deleted file mode 100644 index a838101..0000000 --- a/internal/processor/letter_processor_status.go +++ /dev/null @@ -1,251 +0,0 @@ -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/letter_validation_processor.go b/internal/processor/letter_validation_processor.go deleted file mode 100644 index b695947..0000000 --- a/internal/processor/letter_validation_processor.go +++ /dev/null @@ -1,71 +0,0 @@ -package processor - -import ( - "context" - "errors" - - "eslogad-be/internal/entities" - - "github.com/google/uuid" -) - -type LetterValidationProcessor interface { - ValidateCreateOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing) error - ValidateUpdateOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing, existingLetter *entities.LetterOutgoing) error - ValidateDeleteOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing) error - ValidateApprovalSubmission(ctx context.Context, letter *entities.LetterOutgoing) error -} - -type LetterValidationProcessorImpl struct{} - -func NewLetterValidationProcessor() *LetterValidationProcessorImpl { - return &LetterValidationProcessorImpl{} -} - -func (p *LetterValidationProcessorImpl) ValidateCreateOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing) error { - if letter.Subject == "" { - return errors.New("letter subject is required") - } - - if letter.CreatedBy == uuid.Nil { - return errors.New("letter creator is required") - } - - if letter.IssueDate.IsZero() { - return errors.New("letter issue date is required") - } - - return nil -} - -func (p *LetterValidationProcessorImpl) ValidateUpdateOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing, existingLetter *entities.LetterOutgoing) error { - if existingLetter.Status != entities.LetterOutgoingStatusDraft { - return errors.New("only draft letters can be updated") - } - - if letter.Subject == "" { - return errors.New("letter subject cannot be empty") - } - - return nil -} - -func (p *LetterValidationProcessorImpl) ValidateDeleteOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing) error { - if letter.Status != entities.LetterOutgoingStatusDraft { - return errors.New("only draft letters can be deleted") - } - - return nil -} - -func (p *LetterValidationProcessorImpl) ValidateApprovalSubmission(ctx context.Context, letter *entities.LetterOutgoing) error { - if letter.Status != entities.LetterOutgoingStatusDraft { - return errors.New("only draft letters can be submitted for approval") - } - - if letter.ApprovalFlowID == nil { - return errors.New("approval flow is required for submission") - } - - return nil -} \ No newline at end of file diff --git a/internal/processor/notification_processor.go b/internal/processor/notification_processor.go deleted file mode 100644 index 79c04b9..0000000 --- a/internal/processor/notification_processor.go +++ /dev/null @@ -1,389 +0,0 @@ -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 - SendOutgoingLetterNotification(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 -} - -func (p *NotificationProcessorImpl) SendOutgoingLetterNotification(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 for outgoing letters - url := fmt.Sprintf("/en/apps/surat-keluar-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, - }, - }) -} diff --git a/internal/processor/novu_processor.go b/internal/processor/novu_processor.go deleted file mode 100644 index 2ff86b4..0000000 --- a/internal/processor/novu_processor.go +++ /dev/null @@ -1,280 +0,0 @@ -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/onlyoffice_processor.go b/internal/processor/onlyoffice_processor.go deleted file mode 100644 index c41978f..0000000 --- a/internal/processor/onlyoffice_processor.go +++ /dev/null @@ -1,463 +0,0 @@ -package processor - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type OnlyOfficeProcessor interface { - GetDocumentSessionByKey(ctx context.Context, documentKey string) (*entities.DocumentSession, error) - GetOrCreateDocumentSession(ctx context.Context, documentID, userID uuid.UUID) (*entities.DocumentSession, error) - UpdateDocumentSession(ctx context.Context, session *entities.DocumentSession) error - CreateDocumentVersion(ctx context.Context, version *entities.DocumentVersion) error - UpdateDocumentURL(ctx context.Context, documentID uuid.UUID, newURL string) error - LockDocument(ctx context.Context, documentID, userID uuid.UUID) error - UnlockDocument(ctx context.Context, documentID, userID uuid.UUID) error - LogDocumentError(ctx context.Context, documentID uuid.UUID, errorMsg string, details interface{}) error - GetDocumentDetails(ctx context.Context, documentID uuid.UUID, documentType string) (*DocumentDetails, error) -} - -type DocumentDetails struct { - DocumentID uuid.UUID - FileName string - FileType string - FileURL string - FileSize int64 - DocumentType string - ReferenceID uuid.UUID -} - -type OnlyOfficeProcessorImpl struct { - db *gorm.DB - sessionRepo *repository.DocumentSessionRepository - versionRepo *repository.DocumentVersionRepository - metadataRepo *repository.DocumentMetadataRepository - errorRepo *repository.DocumentErrorRepository - txManager *repository.TxManager -} - -func NewOnlyOfficeProcessor( - db *gorm.DB, - sessionRepo *repository.DocumentSessionRepository, - versionRepo *repository.DocumentVersionRepository, - metadataRepo *repository.DocumentMetadataRepository, - errorRepo *repository.DocumentErrorRepository, - txManager *repository.TxManager, -) *OnlyOfficeProcessorImpl { - return &OnlyOfficeProcessorImpl{ - db: db, - sessionRepo: sessionRepo, - versionRepo: versionRepo, - metadataRepo: metadataRepo, - errorRepo: errorRepo, - txManager: txManager, - } -} - -// GetDocumentSessionByKey retrieves a document session by its OnlyOffice key -func (p *OnlyOfficeProcessorImpl) GetDocumentSessionByKey(ctx context.Context, documentKey string) (*entities.DocumentSession, error) { - return p.sessionRepo.GetByKey(ctx, documentKey) -} - -// GetOrCreateDocumentSession gets an existing session or creates a new one -func (p *OnlyOfficeProcessorImpl) GetOrCreateDocumentSession(ctx context.Context, documentID, userID uuid.UUID) (*entities.DocumentSession, error) { - // Try to get existing active session - session, err := p.sessionRepo.GetActiveByDocument(ctx, documentID) - if err == nil && session != nil { - // Return existing session if it's not closed - if session.Status != 4 { // Status 4 = closed - return session, nil - } - } - - // Create new session - session = &entities.DocumentSession{ - DocumentID: documentID, - UserID: userID, - Status: 0, // Initial status - Version: 1, - IsLocked: false, - } - - // Generate unique document key with retry logic - for attempts := 0; attempts < 3; attempts++ { - session.DocumentKey = p.generateDocumentKey(documentID, userID) - err = p.sessionRepo.Create(ctx, session) - if err == nil { - return session, nil - } - // If it's not a duplicate key error, return the error - if !errors.Is(err, gorm.ErrDuplicatedKey) && !contains(err.Error(), "duplicate key") { - return nil, fmt.Errorf("failed to create document session: %w", err) - } - // Wait a bit before retrying - time.Sleep(time.Millisecond * 10) - } - - return nil, fmt.Errorf("failed to create document session after retries: %w", err) -} - -// UpdateDocumentSession updates an existing document session -func (p *OnlyOfficeProcessorImpl) UpdateDocumentSession(ctx context.Context, session *entities.DocumentSession) error { - return p.sessionRepo.Update(ctx, session) -} - -// CreateDocumentVersion creates a new document version -func (p *OnlyOfficeProcessorImpl) CreateDocumentVersion(ctx context.Context, version *entities.DocumentVersion) error { - return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - // Deactivate previous versions if this is the active one - if version.IsActive { - err := p.versionRepo.DeactivateAllVersions(txCtx, version.DocumentID) - if err != nil { - return fmt.Errorf("failed to deactivate previous versions: %w", err) - } - } - - // Create new version - err := p.versionRepo.Create(txCtx, version) - if err != nil { - return fmt.Errorf("failed to create document version: %w", err) - } - - return nil - }) -} - -// UpdateDocumentURL updates the document URL in the appropriate table -func (p *OnlyOfficeProcessorImpl) UpdateDocumentURL(ctx context.Context, documentID uuid.UUID, newURL string) error { - // Get document metadata to determine type - metadata, err := p.metadataRepo.GetByDocumentID(ctx, documentID) - if err != nil { - return fmt.Errorf("failed to get document metadata: %w", err) - } - - // Update based on document type - switch metadata.DocumentType { - case "letter_attachment": - return p.updateLetterAttachmentURL(ctx, metadata.ReferenceID, newURL) - case "outgoing_attachment": - return p.updateOutgoingAttachmentURL(ctx, metadata.ReferenceID, newURL) - case "discussion_attachment": - return p.updateDiscussionAttachmentURL(ctx, metadata.ReferenceID, newURL) - default: - return fmt.Errorf("unknown document type: %s", metadata.DocumentType) - } -} - -// LockDocument locks a document for editing -func (p *OnlyOfficeProcessorImpl) LockDocument(ctx context.Context, documentID, userID uuid.UUID) error { - session, err := p.sessionRepo.GetActiveByDocument(ctx, documentID) - if err != nil { - return fmt.Errorf("failed to get document session: %w", err) - } - - // Check if already locked by another user - if session.IsLocked && session.LockedBy != nil && *session.LockedBy != userID { - return errors.New("document is already locked by another user") - } - - // Lock the document - session.IsLocked = true - session.LockedBy = &userID - now := time.Now() - session.LockedAt = &now - - return p.sessionRepo.Update(ctx, session) -} - -// UnlockDocument unlocks a document -func (p *OnlyOfficeProcessorImpl) UnlockDocument(ctx context.Context, documentID, userID uuid.UUID) error { - session, err := p.sessionRepo.GetActiveByDocument(ctx, documentID) - if err != nil { - return fmt.Errorf("failed to get document session: %w", err) - } - - // Check if locked by the requesting user - if session.IsLocked && session.LockedBy != nil && *session.LockedBy != userID { - return errors.New("document is locked by another user") - } - - // Unlock the document - session.IsLocked = false - session.LockedBy = nil - session.LockedAt = nil - - return p.sessionRepo.Update(ctx, session) -} - -// LogDocumentError logs an error related to document operations -func (p *OnlyOfficeProcessorImpl) LogDocumentError(ctx context.Context, documentID uuid.UUID, errorMsg string, details interface{}) error { - detailsMap := make(map[string]interface{}) - - // Convert details to map if possible - if details != nil { - detailsJSON, _ := json.Marshal(details) - json.Unmarshal(detailsJSON, &detailsMap) - } - - docError := &entities.DocumentError{ - DocumentID: documentID, - ErrorType: "onlyoffice_callback", - ErrorMsg: errorMsg, - Details: detailsMap, - } - - // Get current session if available - if session, err := p.sessionRepo.GetActiveByDocument(ctx, documentID); err == nil && session != nil { - docError.SessionID = &session.ID - } - - return p.errorRepo.Create(ctx, docError) -} - -// GetDocumentDetails retrieves document details based on type -func (p *OnlyOfficeProcessorImpl) GetDocumentDetails(ctx context.Context, documentID uuid.UUID, documentType string) (*DocumentDetails, error) { - // Get metadata - metadata, err := p.metadataRepo.GetByDocumentID(ctx, documentID) - if err != nil { - // If metadata doesn't exist, create it based on the document type - if errors.Is(err, gorm.ErrRecordNotFound) { - return p.createDocumentMetadata(ctx, documentID, documentType) - } - return nil, err - } - - // Get the actual file URL from the appropriate table - fileURL, err := p.getDocumentURL(ctx, metadata) - if err != nil { - return nil, err - } - - return &DocumentDetails{ - DocumentID: metadata.DocumentID, - FileName: metadata.FileName, - FileType: metadata.FileType, - FileURL: fileURL, - FileSize: metadata.FileSize, - DocumentType: metadata.DocumentType, - ReferenceID: metadata.ReferenceID, - }, nil -} - -// Helper methods - -func (p *OnlyOfficeProcessorImpl) generateDocumentKey(documentID, userID uuid.UUID) string { - // Use nanoseconds for better precision - now := time.Now().UnixNano() - - // Generate random bytes for additional uniqueness - randomBytes := make([]byte, 4) - rand.Read(randomBytes) - randomHex := hex.EncodeToString(randomBytes) - - return fmt.Sprintf("%s_%s_%d_%s", documentID.String()[:8], userID.String()[:8], now, randomHex) -} - -func (p *OnlyOfficeProcessorImpl) updateLetterAttachmentURL(ctx context.Context, attachmentID uuid.UUID, newURL string) error { - // Update letter attachment URL - return p.db.WithContext(ctx). - Table("letter_incoming_attachments"). - Where("id = ?", attachmentID). - Update("file_url", newURL).Error -} - -func (p *OnlyOfficeProcessorImpl) updateOutgoingAttachmentURL(ctx context.Context, attachmentID uuid.UUID, newURL string) error { - // Update outgoing letter attachment URL - return p.db.WithContext(ctx). - Table("letter_outgoing_attachments"). - Where("id = ?", attachmentID). - Update("file_url", newURL).Error -} - -func (p *OnlyOfficeProcessorImpl) updateDiscussionAttachmentURL(ctx context.Context, attachmentID uuid.UUID, newURL string) error { - // Update discussion attachment URL - return p.db.WithContext(ctx). - Table("letter_outgoing_discussion_attachments"). - Where("id = ?", attachmentID). - Update("file_url", newURL).Error -} - -func (p *OnlyOfficeProcessorImpl) createDocumentMetadata(ctx context.Context, documentID uuid.UUID, documentType string) (*DocumentDetails, error) { - var metadata entities.DocumentMetadata - var details DocumentDetails - - // Fetch document information based on type - switch documentType { - case "letter_attachment": - var attachment struct { - ID uuid.UUID `gorm:"column:id"` - LetterID uuid.UUID `gorm:"column:letter_id"` - FileName string `gorm:"column:file_name"` - FileURL string `gorm:"column:file_url"` - FileSize int64 `gorm:"column:file_size"` - FileType string `gorm:"column:file_type"` - } - err := p.db.WithContext(ctx). - Table("letter_incoming_attachments"). - Where("id = ?", documentID). - First(&attachment).Error - if err != nil { - return nil, fmt.Errorf("failed to get letter attachment: %w", err) - } - - metadata = entities.DocumentMetadata{ - DocumentID: documentID, - DocumentType: documentType, - ReferenceID: attachment.LetterID, - FileName: attachment.FileName, - FileType: attachment.FileType, - FileSize: attachment.FileSize, - } - details = DocumentDetails{ - DocumentID: documentID, - FileName: attachment.FileName, - FileType: attachment.FileType, - FileURL: attachment.FileURL, - FileSize: attachment.FileSize, - DocumentType: documentType, - ReferenceID: attachment.LetterID, - } - - case "outgoing_attachment": - var attachment struct { - ID uuid.UUID `gorm:"column:id"` - LetterID uuid.UUID `gorm:"column:letter_id"` - FileName string `gorm:"column:file_name"` - FileURL string `gorm:"column:file_url"` - FileSize int64 `gorm:"column:file_size"` - FileType string `gorm:"column:file_type"` - } - err := p.db.WithContext(ctx). - Table("letter_outgoing_attachments"). - Where("id = ?", documentID). - First(&attachment).Error - if err != nil { - return nil, fmt.Errorf("failed to get outgoing letter attachment: %w", err) - } - - metadata = entities.DocumentMetadata{ - DocumentID: documentID, - DocumentType: documentType, - ReferenceID: attachment.LetterID, - FileName: attachment.FileName, - FileType: attachment.FileType, - FileSize: attachment.FileSize, - } - details = DocumentDetails{ - DocumentID: documentID, - FileName: attachment.FileName, - FileType: attachment.FileType, - FileURL: attachment.FileURL, - FileSize: attachment.FileSize, - DocumentType: documentType, - ReferenceID: attachment.LetterID, - } - - case "discussion_attachment": - var attachment struct { - ID uuid.UUID `gorm:"column:id"` - DiscussionID uuid.UUID `gorm:"column:discussion_id"` - FileName string `gorm:"column:file_name"` - FileURL string `gorm:"column:file_url"` - FileSize int64 `gorm:"column:file_size"` - FileType string `gorm:"column:file_type"` - } - err := p.db.WithContext(ctx). - Table("letter_outgoing_discussion_attachments"). - Where("id = ?", documentID). - First(&attachment).Error - if err != nil { - return nil, fmt.Errorf("failed to get discussion attachment: %w", err) - } - - metadata = entities.DocumentMetadata{ - DocumentID: documentID, - DocumentType: documentType, - ReferenceID: attachment.DiscussionID, - FileName: attachment.FileName, - FileType: attachment.FileType, - FileSize: attachment.FileSize, - } - details = DocumentDetails{ - DocumentID: documentID, - FileName: attachment.FileName, - FileType: attachment.FileType, - FileURL: attachment.FileURL, - FileSize: attachment.FileSize, - DocumentType: documentType, - ReferenceID: attachment.DiscussionID, - } - - default: - return nil, fmt.Errorf("unknown document type: %s", documentType) - } - - // Create metadata in database - err := p.metadataRepo.Create(ctx, &metadata) - if err != nil { - // If it already exists (race condition), try to get it - if strings.Contains(err.Error(), "duplicate key") { - existingMetadata, getErr := p.metadataRepo.GetByDocumentID(ctx, documentID) - if getErr == nil { - // Get the file URL - fileURL, urlErr := p.getDocumentURL(ctx, existingMetadata) - if urlErr == nil { - details.FileURL = fileURL - } - return &details, nil - } - } - return nil, fmt.Errorf("failed to create document metadata: %w", err) - } - - return &details, nil -} - -func (p *OnlyOfficeProcessorImpl) getDocumentURL(ctx context.Context, metadata *entities.DocumentMetadata) (string, error) { - var fileURL string - - switch metadata.DocumentType { - case "letter_attachment": - err := p.db.WithContext(ctx). - Table("letter_incoming_attachments"). - Where("id = ?", metadata.ReferenceID). - Select("file_url"). - Scan(&fileURL).Error - return fileURL, err - - case "outgoing_attachment": - err := p.db.WithContext(ctx). - Table("letter_outgoing_attachments"). - Where("id = ?", metadata.ReferenceID). - Select("file_url"). - Scan(&fileURL).Error - return fileURL, err - - case "discussion_attachment": - err := p.db.WithContext(ctx). - Table("letter_outgoing_discussion_attachments"). - Where("id = ?", metadata.ReferenceID). - Select("file_url"). - Scan(&fileURL).Error - return fileURL, err - - default: - return "", fmt.Errorf("unknown document type: %s", metadata.DocumentType) - } -} - -// Helper function to check if string contains substring -func contains(s, substr string) bool { - return strings.Contains(s, substr) -} \ No newline at end of file diff --git a/internal/processor/recipient_processor.go b/internal/processor/recipient_processor.go deleted file mode 100644 index 4e3f5b7..0000000 --- a/internal/processor/recipient_processor.go +++ /dev/null @@ -1,110 +0,0 @@ -package processor - -import ( - "context" - "eslogad-be/internal/appcontext" - "time" - - "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) { - userCreatorDepartment := appcontext.FromGinContext(ctx).DepartmentID - departmentIDs = append(departmentIDs, userCreatorDepartment) - - userMemberships, err := p.userDeptRepo.ListActiveByDepartmentIDs(ctx, departmentIDs) - if err != nil { - return nil, err - } - - recipients := p.buildUniqueRecipients(letterID, userMemberships, userCreatorDepartment) - - 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, userCreatorDepartment uuid.UUID) []entities.LetterIncomingRecipient { - var recipients []entities.LetterIncomingRecipient - userMap := make(map[string]bool) - now := time.Now() - - for _, membership := range userMemberships { - userIDStr := membership.UserID.String() - - if !userMap[userIDStr] { - userID := membership.UserID - departmentID := membership.DepartmentID - - if userCreatorDepartment == membership.DepartmentID { - recipients = append(recipients, entities.LetterIncomingRecipient{ - LetterID: letterID, - RecipientUserID: &userID, - RecipientDepartmentID: &departmentID, - Status: entities.RecipientStatusCompleted, - ReadAt: &now, - CompletedAt: &now, - }) - } else { - recipients = append(recipients, entities.LetterIncomingRecipient{ - LetterID: letterID, - RecipientUserID: &userID, - RecipientDepartmentID: &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/repository_attachment_processor.go b/internal/processor/repository_attachment_processor.go deleted file mode 100644 index 5432660..0000000 --- a/internal/processor/repository_attachment_processor.go +++ /dev/null @@ -1,78 +0,0 @@ -package processor - -import ( - "context" - "eslogad-be/internal/appcontext" - "eslogad-be/internal/contract" - "eslogad-be/internal/transformer" - "fmt" - - "github.com/google/uuid" -) - -type RepositoryAttachmentProcessorImpl struct { - attachmentRepo RepositoryAttachmentRepository -} - -func NewRepositoryAttachmentProcessor(attachmentRepo RepositoryAttachmentRepository) *RepositoryAttachmentProcessorImpl { - return &RepositoryAttachmentProcessorImpl{ - attachmentRepo: attachmentRepo, - } -} - -func (p *RepositoryAttachmentProcessorImpl) CreateAttachment(ctx context.Context, req *contract.CreateRepositoryAttachmentRequest) (*contract.RepositoryAttachmentsResponse, error) { - userID := getUserIDFromContext(ctx) - - attachmentEntity := transformer.CreateRepositoryAttachmentRequestToEntity(req, userID) - - err := p.attachmentRepo.Create(ctx, attachmentEntity) - if err != nil { - return nil, fmt.Errorf("failed to create repository attachment: %w", err) - } - - return transformer.RepositoryAttachmentEntityToContract(attachmentEntity), nil -} - -func getUserIDFromContext(ctx context.Context) uuid.UUID { - appCtx := appcontext.FromGinContext(ctx) - if appCtx != nil { - return appCtx.UserID - } - return uuid.New() -} - -func (p *RepositoryAttachmentProcessorImpl) DeleteAttachment(ctx context.Context, id uuid.UUID) error { - _, err := p.attachmentRepo.GetByID(ctx, id) - if err != nil { - return fmt.Errorf("repository attachment not found: %w", err) - } - - err = p.attachmentRepo.Delete(ctx, id) - if err != nil { - return fmt.Errorf("failed to delete repository attachment: %w", err) - } - - return nil -} - -func (p *RepositoryAttachmentProcessorImpl) GetById(ctx context.Context, id uuid.UUID) (*contract.RepositoryAttachmentsResponse, error) { - attachment, err := p.attachmentRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("repository attachment not found: %w", err) - } - - resp := transformer.RepositoryAttachmentEntityToContract(attachment) - - return resp, nil -} - -func (p *RepositoryAttachmentProcessorImpl) ListAttachment(ctx context.Context, search *string, limit, offset int) ([]contract.RepositoryAttachmentsResponse, int, error) { - attachments, totalCount, err := p.attachmentRepo.List(ctx, search, limit, offset) - if err != nil { - return nil, 0, fmt.Errorf("failed to get users: %w", err) - } - - responses := transformer.RepositoryAttachmentEntityToContracts(attachments) - - return responses, int(totalCount), nil -} diff --git a/internal/processor/repository_attachment_repository.go b/internal/processor/repository_attachment_repository.go deleted file mode 100644 index 8b29edf..0000000 --- a/internal/processor/repository_attachment_repository.go +++ /dev/null @@ -1,16 +0,0 @@ -package processor - -import ( - "context" - "eslogad-be/internal/entities" - - "github.com/google/uuid" -) - -type RepositoryAttachmentRepository interface { - Create(ctx context.Context, user *entities.RepositoryAttachment) error - GetByID(ctx context.Context, id uuid.UUID) (*entities.RepositoryAttachment, error) - Update(ctx context.Context, user *entities.RepositoryAttachment) error - Delete(ctx context.Context, id uuid.UUID) error - List(ctx context.Context, search *string, limit, offset int) ([]*entities.RepositoryAttachment, int64, error) -} diff --git a/internal/processor/user_processor.go b/internal/processor/user_processor.go deleted file mode 100644 index 1353074..0000000 --- a/internal/processor/user_processor.go +++ /dev/null @@ -1,423 +0,0 @@ -package processor - -import ( - "context" - "fmt" - - "golang.org/x/crypto/bcrypt" - - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" - "eslogad-be/internal/transformer" - - "github.com/google/uuid" -) - -type UserProcessorImpl struct { - userRepo UserRepository - profileRepo UserProfileRepository - novuProcessor NovuProcessor - userRoleProc UserRoleProcessor -} - -type UserProfileRepository interface { - GetByUserID(ctx context.Context, userID uuid.UUID) (*entities.UserProfile, error) - Create(ctx context.Context, profile *entities.UserProfile) error - Upsert(ctx context.Context, profile *entities.UserProfile) error - Update(ctx context.Context, profile *entities.UserProfile) error -} - -func NewUserProcessor( - userRepo UserRepository, - profileRepo UserProfileRepository, - userRoleProc UserRoleProcessor, -) *UserProcessorImpl { - return &UserProcessorImpl{ - userRepo: userRepo, - profileRepo: profileRepo, - userRoleProc: userRoleProc, - } -} - -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 { - return nil, fmt.Errorf("user with email %s already exists", req.Email) - } - - passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - return nil, fmt.Errorf("failed to hash password: %w", err) - } - - userEntity := transformer.CreateUserRequestToEntity(req, string(passwordHash)) - - err = p.userRepo.Create(ctx, userEntity) - if err != nil { - return nil, fmt.Errorf("failed to create user: %w", err) - } - - defaultFullName := userEntity.Name - profile := &entities.UserProfile{ - UserID: userEntity.ID, - FullName: defaultFullName, - Timezone: "Asia/Jakarta", - Locale: "id-ID", - Preferences: entities.JSONB{}, - NotificationPrefs: entities.JSONB{}, - } - _ = p.profileRepo.Create(ctx, profile) - - if req.RoleID != nil { - if err := p.userRoleProc.AssignRoleToUser(ctx, userEntity.ID, *req.RoleID); err != nil { - return nil, fmt.Errorf("failed to assign role to user: %w", err) - } - } - - // Assign departments if provided - if len(req.DepartmentIDs) > 0 { - departments := make([]entities.Department, len(req.DepartmentIDs)) - for i, deptID := range req.DepartmentIDs { - departments[i] = entities.Department{ID: deptID} - } - if err := p.userRepo.UpdateDepartments(ctx, userEntity.ID, departments); err != nil { - return nil, fmt.Errorf("failed to assign departments: %w", err) - } - } - - if p.novuProcessor != nil { - if err := p.novuProcessor.CreateSubscriber(ctx, userEntity); err != nil { - _ = err - } - } - - // Fetch the user with departments for response - userWithDepts, _ := p.userRepo.GetByIDWithDepartments(ctx, userEntity.ID) - if userWithDepts != nil { - userEntity = userWithDepts - } - - return transformer.EntityToContract(userEntity), nil -} - -func (p *UserProcessorImpl) UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) { - existingUser, err := p.userRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("user not found: %w", err) - } - - if req.Email != nil && *req.Email != existingUser.Email { - existingUserByEmail, err := p.userRepo.GetByEmail(ctx, *req.Email) - if err == nil && existingUserByEmail != nil && existingUserByEmail.ID != id { - return nil, fmt.Errorf("user with email %s already exists", *req.Email) - } - } - - updated := transformer.UpdateUserEntity(existingUser, req) - fmt.Printf("Test Updated: %+v\n", updated) - - err = p.userRepo.Update(ctx, updated) - if err != nil { - return nil, fmt.Errorf("failed to update user: %w", err) - } - - if req.Name != nil { - profile, err := p.profileRepo.GetByUserID(ctx, updated.ID) - if err != nil { - return nil, fmt.Errorf("failed to get user profile: %w", err) - } - profile.FullName = *req.Name - if err := p.profileRepo.Update(ctx, profile); err != nil { - return nil, fmt.Errorf("failed to update user profile: %w", err) - } - } - - if req.Role != nil { - if err := p.userRoleProc.ReplaceUserRole(ctx, updated.ID, *req.Role); err != nil { - return nil, fmt.Errorf("failed to assign role to user: %w", err) - } - } - - // Update departments if provided - if req.DepartmentIDs != nil { - departments := make([]entities.Department, len(*req.DepartmentIDs)) - for i, deptID := range *req.DepartmentIDs { - departments[i] = entities.Department{ID: deptID} - } - if err := p.userRepo.UpdateDepartments(ctx, updated.ID, departments); err != nil { - return nil, fmt.Errorf("failed to update departments: %w", err) - } - } - - // Update Novu subscriber - if p.novuProcessor != nil { - if err := p.novuProcessor.UpdateSubscriber(ctx, updated); err != nil { - _ = err - } - } - - // Fetch the user with departments for response - userWithDepts, _ := p.userRepo.GetByIDWithDepartments(ctx, updated.ID) - if userWithDepts != nil { - updated = userWithDepts - } - - return transformer.EntityToContract(updated), nil -} - -func (p *UserProcessorImpl) DeleteUser(ctx context.Context, id uuid.UUID) error { - _, err := p.userRepo.GetByID(ctx, id) - if err != nil { - return fmt.Errorf("user not found: %w", err) - } - - err = p.userRepo.Delete(ctx, id) - if err != nil { - 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 -} - -func (p *UserProcessorImpl) GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) { - user, err := p.userRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("user not found: %w", err) - } - resp := transformer.EntityToContract(user) - if resp != nil { - // Roles are loaded separately since they're not preloaded - if roles, err := p.userRepo.GetRolesByUserID(ctx, resp.ID); err == nil { - resp.Roles = transformer.RolesToContract(roles) - } - // Departments are now preloaded, so they're already in the response - } - return resp, nil -} - -// 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 { - return nil, fmt.Errorf("user not found: %w", err) - } - - // Departments are now preloaded, so they're already in the response - return transformer.EntityToContract(user), nil -} - -func (p *UserProcessorImpl) ListUsersWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]contract.UserResponse, int, error) { - users, totalCount, err := p.userRepo.ListWithFilters(ctx, search, roleCode, isActive, limit, offset) - if err != nil { - return nil, 0, fmt.Errorf("failed to get users: %w", err) - } - - responses := transformer.EntitiesToContracts(users) - userIDs := make([]uuid.UUID, 0, len(responses)) - for i := range responses { - userIDs = append(userIDs, responses[i].ID) - } - // Roles are loaded separately since they're not preloaded - rolesMap, err := p.userRepo.GetRolesByUserIDs(ctx, userIDs) - if err == nil { - for i := range responses { - if roles, ok := rolesMap[responses[i].ID]; ok { - responses[i].Roles = transformer.RolesToContract(roles) - } - } - } - // Departments are now preloaded, so they're already in the responses - return responses, int(totalCount), nil -} - -func (p *UserProcessorImpl) GetUserEntityByEmail(ctx context.Context, email string) (*entities.User, error) { - user, err := p.userRepo.GetByEmail(ctx, email) - if err != nil { - return nil, fmt.Errorf("user not found: %w", err) - } - - return user, nil -} - -func (p *UserProcessorImpl) ChangePassword(ctx context.Context, userID uuid.UUID, req *contract.ChangePasswordRequest) error { - user, err := p.userRepo.GetByID(ctx, userID) - if err != nil { - return fmt.Errorf("user not found: %w", err) - } - - err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)) - if err != nil { - return fmt.Errorf("current password is incorrect") - } - - newPasswordHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("failed to hash new password: %w", err) - } - - err = p.userRepo.UpdatePassword(ctx, userID, string(newPasswordHash)) - if err != nil { - return fmt.Errorf("failed to update password: %w", err) - } - - return nil -} - -func (p *UserProcessorImpl) ChangeUserPassword(ctx context.Context, userID uuid.UUID, req *contract.ChangeUserPasswordRequest) error { - _, err := p.userRepo.GetByID(ctx, userID) - if err != nil { - return fmt.Errorf("user not found: %w", err) - } - - newPasswordHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("failed to hash new password: %w", err) - } - - err = p.userRepo.UpdatePassword(ctx, userID, string(newPasswordHash)) - if err != nil { - return fmt.Errorf("failed to update password: %w", err) - } - - return nil -} - -func (p *UserProcessorImpl) ActivateUser(ctx context.Context, userID uuid.UUID) error { - _, err := p.userRepo.GetByID(ctx, userID) - if err != nil { - return fmt.Errorf("user not found: %w", err) - } - - err = p.userRepo.UpdateActiveStatus(ctx, userID, true) - if err != nil { - return fmt.Errorf("failed to activate user: %w", err) - } - - return nil -} - -func (p *UserProcessorImpl) DeactivateUser(ctx context.Context, userID uuid.UUID) error { - _, err := p.userRepo.GetByID(ctx, userID) - if err != nil { - return fmt.Errorf("user not found: %w", err) - } - - err = p.userRepo.UpdateActiveStatus(ctx, userID, false) - if err != nil { - return fmt.Errorf("failed to deactivate user: %w", err) - } - - return nil -} - -// RBAC implementations -func (p *UserProcessorImpl) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]contract.RoleResponse, error) { - roles, err := p.userRepo.GetRolesByUserID(ctx, userID) - if err != nil { - return nil, err - } - return transformer.RolesToContract(roles), nil -} - -func (p *UserProcessorImpl) GetUserPermissionCodes(ctx context.Context, userID uuid.UUID) ([]string, error) { - perms, err := p.userRepo.GetPermissionsByUserID(ctx, userID) - if err != nil { - return nil, err - } - codes := make([]string, 0, len(perms)) - for _, p := range perms { - codes = append(codes, p.Code) - } - return codes, nil -} - -func (p *UserProcessorImpl) GetUserDepartments(ctx context.Context, userID uuid.UUID) ([]contract.DepartmentResponse, error) { - departments, err := p.userRepo.GetDepartmentsByUserID(ctx, userID) - if err != nil { - return nil, err - } - return transformer.DepartmentsToContract(departments), nil -} - -func (p *UserProcessorImpl) GetUserProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) { - prof, err := p.profileRepo.GetByUserID(ctx, userID) - if err != nil { - return nil, err - } - return transformer.ProfileEntityToContract(prof), nil -} - -func (p *UserProcessorImpl) UpdateUserProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) { - existing, _ := p.profileRepo.GetByUserID(ctx, userID) - entity := transformer.ProfileUpdateToEntity(userID, req, existing) - if existing == nil { - if err := p.profileRepo.Create(ctx, entity); err != nil { - return nil, err - } - } else { - if err := p.profileRepo.Update(ctx, entity); err != nil { - return nil, err - } - } - return transformer.ProfileEntityToContract(entity), nil -} - -// GetActiveUsersForMention retrieves active users for mention purposes with optional username search -func (p *UserProcessorImpl) GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) { - // Limit validation is handled in the service layer - - // Set isActive to true to only get active users - isActive := true - users, _, err := p.userRepo.ListWithFilters(ctx, search, nil, &isActive, limit, 0) - if err != nil { - return nil, fmt.Errorf("failed to get active users: %w", err) - } - - responses := transformer.EntitiesToContracts(users) - userIDs := make([]uuid.UUID, 0, len(responses)) - for i := range responses { - userIDs = append(userIDs, responses[i].ID) - } - - // Load roles for the users - rolesMap, err := p.userRepo.GetRolesByUserIDs(ctx, userIDs) - if err == nil { - for i := range responses { - if roles, ok := rolesMap[responses[i].ID]; ok { - responses[i].Roles = transformer.RolesToContract(roles) - } - } - } - - return responses, nil -} diff --git a/internal/processor/user_repository.go b/internal/processor/user_repository.go deleted file mode 100644 index f286bed..0000000 --- a/internal/processor/user_repository.go +++ /dev/null @@ -1,34 +0,0 @@ -package processor - -import ( - "context" - "eslogad-be/internal/entities" - - "github.com/google/uuid" -) - -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) - Update(ctx context.Context, user *entities.User) error - Delete(ctx context.Context, id uuid.UUID) error - UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error - UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error - List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.User, int64, error) - Count(ctx context.Context, filters map[string]interface{}) (int64, error) - - GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) - GetPermissionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Permission, error) - GetDepartmentsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Department, error) - - // New optimized helpers - GetRolesByUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]entities.Role, error) - ListWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]*entities.User, int64, error) - - GetByIDWithDepartments(ctx context.Context, id uuid.UUID) (*entities.User, error) - UpdateDepartments(ctx context.Context, userID uuid.UUID, departments []entities.Department) error -} diff --git a/internal/processor/user_role_processor.go b/internal/processor/user_role_processor.go deleted file mode 100644 index 2dc4a31..0000000 --- a/internal/processor/user_role_processor.go +++ /dev/null @@ -1,131 +0,0 @@ -package processor - -import ( - "context" - "time" - - "eslogad-be/internal/entities" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type UserRoleProcessor interface { - AssignRoleToUser(ctx context.Context, userID, roleID uuid.UUID) error - RemoveRoleFromUser(ctx context.Context, userID, roleID uuid.UUID) error - GetUserRoles(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) - HasRole(ctx context.Context, userID uuid.UUID, roleCode string) (bool, error) - ReplaceUserRole(ctx context.Context, userID, roleID uuid.UUID) error -} - -type UserRoleProcessorImpl struct { - db *gorm.DB -} - -func NewUserRoleProcessor(db *gorm.DB) *UserRoleProcessorImpl { - return &UserRoleProcessorImpl{ - db: db, - } -} - -type UserRoleEntry struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"` - UserID uuid.UUID `gorm:"type:uuid;not null"` - RoleID uuid.UUID `gorm:"type:uuid;not null"` - AssignedAt time.Time `gorm:"default:CURRENT_TIMESTAMP"` - RemovedAt *time.Time -} - -func (UserRoleEntry) TableName() string { - return "user_role" -} - -func (p *UserRoleProcessorImpl) AssignRoleToUser(ctx context.Context, userID, roleID uuid.UUID) error { - var existingEntry UserRoleEntry - err := p.db.WithContext(ctx). - Where("user_id = ? AND role_id = ? AND removed_at IS NULL", userID, roleID). - First(&existingEntry).Error - - if err == nil { - return nil - } - - if err != gorm.ErrRecordNotFound { - return err - } - - newEntry := UserRoleEntry{ - UserID: userID, - RoleID: roleID, - AssignedAt: time.Now(), - } - - return p.db.WithContext(ctx).Create(&newEntry).Error -} - -func (p *UserRoleProcessorImpl) RemoveRoleFromUser(ctx context.Context, userID, roleID uuid.UUID) error { - now := time.Now() - return p.db.WithContext(ctx). - Model(&UserRoleEntry{}). - Where("user_id = ? AND role_id = ? AND removed_at IS NULL", userID, roleID). - Update("removed_at", now).Error -} - -func (p *UserRoleProcessorImpl) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) { - var roles []entities.Role - err := p.db.WithContext(ctx). - Table("roles as r"). - Select("r.*"). - Joins("JOIN user_role ur ON ur.role_id = r.id AND ur.removed_at IS NULL"). - Where("ur.user_id = ?", userID). - Find(&roles).Error - return roles, err -} - -func (p *UserRoleProcessorImpl) HasRole(ctx context.Context, userID uuid.UUID, roleCode string) (bool, error) { - var count int64 - err := p.db.WithContext(ctx). - Table("user_role as ur"). - Joins("JOIN roles r ON r.id = ur.role_id"). - Where("ur.user_id = ? AND r.code = ? AND ur.removed_at IS NULL", userID, roleCode). - Count(&count).Error - - if err != nil { - return false, err - } - - return count > 0, nil -} - -func (p *UserRoleProcessorImpl) ReplaceUserRole(ctx context.Context, userID uuid.UUID, roleID uuid.UUID) error { - return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // Delete old role - if err := tx.Where("user_id = ?", userID). - Delete(&UserRoleEntry{}).Error; err != nil { - return err - } - - // Check if new role already exists - var existingEntry UserRoleEntry - err := tx.Where("user_id = ?", userID). - First(&existingEntry).Error - - if err == nil { - // Role already assigned - return nil - } - - if err != gorm.ErrRecordNotFound { - return err - } - - // Assign new role - newEntry := UserRoleEntry{ - UserID: userID, - RoleID: roleID, - AssignedAt: time.Now(), - } - - return tx.Create(&newEntry).Error - }) -} \ No newline at end of file diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go deleted file mode 100644 index e5712fe..0000000 --- a/internal/repository/analytics_repository.go +++ /dev/null @@ -1,959 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "log" - "strings" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type AnalyticsRepository struct { - db *gorm.DB -} - -func NewAnalyticsRepository(db *gorm.DB) *AnalyticsRepository { - return &AnalyticsRepository{db: db} -} - -// GetLetterSummaryStats gets overall summary statistics using summary tables for better performance -func (r *AnalyticsRepository) GetLetterSummaryStats(ctx context.Context, startDate, endDate time.Time, userID, departmentID *uuid.UUID) (map[string]interface{}, error) { - db := DBFromContext(ctx, r.db) - stats := make(map[string]interface{}) - - // Use summary tables for better performance when possible - if userID == nil && departmentID != nil { - // Use department_letter_summary for department-specific stats - query := db.Table("department_letter_summary"). - Where("department_id = ?", *departmentID) - - if !startDate.IsZero() { - query = query.Where("summary_date >= ?", startDate) - } - if !endDate.IsZero() { - query = query.Where("summary_date <= ?", endDate) - } - - var result struct { - TotalIncoming int64 `gorm:"column:total_incoming"` - TotalOutgoing int64 `gorm:"column:total_outgoing"` - PendingOutgoing int64 `gorm:"column:pending_outgoing"` - ApprovedOutgoing int64 `gorm:"column:approved_outgoing"` - RejectedOutgoing int64 `gorm:"column:rejected_outgoing"` - AvgResponseHours float64 `gorm:"column:avg_response_hours"` - CompletionRate float64 `gorm:"column:completion_rate"` - } - - query.Select(` - COALESCE(SUM(incoming_count), 0) as total_incoming, - COALESCE(SUM(outgoing_count), 0) as total_outgoing, - COALESCE(SUM(pending_outgoing), 0) as pending_outgoing, - COALESCE(SUM(approved_outgoing), 0) as approved_outgoing, - COALESCE(SUM(rejected_outgoing), 0) as rejected_outgoing, - COALESCE(AVG(avg_response_hours), 0) as avg_response_hours, - COALESCE(AVG(completion_rate), 0) as completion_rate - `).Scan(&result) - - stats["total_incoming"] = result.TotalIncoming - stats["total_outgoing"] = result.TotalOutgoing - stats["total_pending"] = result.PendingOutgoing - stats["total_approved"] = result.ApprovedOutgoing - stats["total_rejected"] = result.RejectedOutgoing - stats["total_archived"] = int64(0) // Calculate separately if needed - stats["avg_processing_time"] = result.AvgResponseHours - stats["completion_rate"] = result.CompletionRate - } else if userID == nil && departmentID == nil { - // Use letter_summary for overall stats - query := db.Table("letter_summary") - - if !startDate.IsZero() { - query = query.Where("summary_date >= ?", startDate) - } - if !endDate.IsZero() { - query = query.Where("summary_date <= ?", endDate) - } - - var result struct { - TotalIncoming int64 `gorm:"column:total_incoming"` - TotalOutgoing int64 `gorm:"column:total_outgoing"` - TotalPending int64 `gorm:"column:total_pending"` - TotalApproved int64 `gorm:"column:total_approved"` - TotalRejected int64 `gorm:"column:total_rejected"` - TotalArchived int64 `gorm:"column:total_archived"` - TotalSent int64 `gorm:"column:total_sent"` - AvgProcessing float64 `gorm:"column:avg_processing"` - } - - query.Select(` - COALESCE(SUM(CASE WHEN letter_type = 'incoming' THEN total_count ELSE 0 END), 0) as total_incoming, - COALESCE(SUM(CASE WHEN letter_type = 'outgoing' THEN total_count ELSE 0 END), 0) as total_outgoing, - COALESCE(SUM(pending_count), 0) as total_pending, - COALESCE(SUM(approved_count), 0) as total_approved, - COALESCE(SUM(rejected_count), 0) as total_rejected, - COALESCE(SUM(archived_count), 0) as total_archived, - COALESCE(SUM(sent_count), 0) as total_sent, - COALESCE(AVG(avg_processing_hours), 0) as avg_processing - `).Scan(&result) - - stats["total_incoming"] = result.TotalIncoming - stats["total_outgoing"] = result.TotalOutgoing - stats["total_pending"] = result.TotalPending - stats["total_approved"] = result.TotalApproved - stats["total_rejected"] = result.TotalRejected - stats["total_archived"] = result.TotalArchived - stats["avg_processing_time"] = result.AvgProcessing - - // Calculate completion rate - completionRate := float64(0) - if result.TotalOutgoing > 0 { - completedCount := result.TotalSent + result.TotalArchived - completionRate = float64(completedCount) / float64(result.TotalOutgoing) * 100 - } - stats["completion_rate"] = completionRate - } else { - // Fall back to original implementation for user-specific queries - // Base query builders - incomingQuery := db.Table("letters_incoming").Where("letters_incoming.deleted_at IS NULL") - outgoingQuery := db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL") - - // Apply date filters - if !startDate.IsZero() { - incomingQuery = incomingQuery.Where("letters_incoming.created_at >= ?", startDate) - outgoingQuery = outgoingQuery.Where("letters_outgoing.created_at >= ?", startDate) - } - if !endDate.IsZero() { - incomingQuery = incomingQuery.Where("letters_incoming.created_at <= ?", endDate) - outgoingQuery = outgoingQuery.Where("letters_outgoing.created_at <= ?", endDate) - } - - // Apply user/department filters for outgoing letters - if userID != nil { - outgoingQuery = outgoingQuery. - Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id"). - Where("letter_outgoing_recipients.user_id = ?", *userID) - incomingQuery = incomingQuery. - Joins("LEFT JOIN letter_incoming_recipients ON letter_incoming_recipients.letter_id = letters_incoming.id"). - Where("letter_incoming_recipients.recipient_user_id = ?", *userID) - } - - fmt.Printf("[DEBUG] userId analitycs: %v\n", userID) - - // Count incoming letters - var totalIncoming int64 - incomingQuery.Distinct("letters_incoming.id").Count(&totalIncoming) - stats["total_incoming"] = totalIncoming - - // Count outgoing letters - var totalOutgoing int64 - outgoingQuery.Distinct("letters_outgoing.id").Count(&totalOutgoing) - stats["total_outgoing"] = totalOutgoing - - // Count by status - need to clone query for each count - var pendingCount, approvedCount, rejectedCount, archivedCount int64 - - db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL"). - Where("letters_outgoing.status = ?", "pending_approval"). - Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id"). - Where("letter_outgoing_recipients.user_id = ?", *userID). - Count(&pendingCount) - - db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL"). - Where("letters_outgoing.status = ?", "approved"). - Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id"). - Where("letter_outgoing_recipients.user_id = ?", *userID). - Count(&approvedCount) - - db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL"). - Where("letters_outgoing.status = ?", "rejected"). - Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id"). - Where("letter_outgoing_recipients.user_id = ?", *userID). - Count(&rejectedCount) - - db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL"). - Where("letters_outgoing.status = ?", "archived"). - Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id"). - Where("letter_outgoing_recipients.user_id = ?", *userID). - Count(&archivedCount) - - stats["total_pending"] = pendingCount - stats["total_approved"] = approvedCount - stats["total_rejected"] = rejectedCount - stats["total_archived"] = archivedCount - - // Calculate average processing time - 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) - - if avgProcessingTime != nil { - stats["avg_processing_time"] = *avgProcessingTime - } else { - stats["avg_processing_time"] = float64(0) - } - - // Calculate completion rate - var completedCount int64 - db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL"). - Where("letters_outgoing.status IN ('sent', 'archived')"). - Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id"). - Where("letter_outgoing_recipients.user_id = ?", *userID). - Count(&completedCount) - - completionRate := float64(0) - if totalOutgoing > 0 { - completionRate = float64(completedCount) / float64(totalOutgoing) * 100 - } - stats["completion_rate"] = completionRate - } - - return stats, nil -} - -// GetStatusDistribution gets letter distribution by status -func (r *AnalyticsRepository) GetStatusDistribution(ctx context.Context, startDate, endDate time.Time, userID *uuid.UUID) ([]map[string]interface{}, error) { - db := DBFromContext(ctx, r.db) - var results []map[string]interface{} - - query := ` - WITH combined_letters AS ( - SELECT - status, - 'incoming' as type, - COUNT(*) as count - FROM letters_incoming - WHERE deleted_at IS NULL - %s - GROUP BY status - - UNION ALL - - SELECT - lo.status, - 'outgoing' as type, - COUNT(DISTINCT lo.id) as count - FROM letters_outgoing lo - %s - WHERE lo.deleted_at IS NULL - %s - GROUP BY lo.status - ) - SELECT - status, - type, - count, - ROUND(count * 100.0 / SUM(count) OVER (PARTITION BY type), 2) as percentage - FROM combined_letters - ORDER BY type, count DESC - ` - - incomingDateFilter := "" - outgoingDateFilter := "" - if !startDate.IsZero() { - incomingDateFilter += fmt.Sprintf(" AND created_at >= '%s'", startDate.Format("2006-01-02")) - outgoingDateFilter += fmt.Sprintf(" AND lo.created_at >= '%s'", startDate.Format("2006-01-02")) - } - if !endDate.IsZero() { - incomingDateFilter += fmt.Sprintf(" AND created_at <= '%s'", endDate.Format("2006-01-02")) - outgoingDateFilter += fmt.Sprintf(" AND lo.created_at <= '%s'", endDate.Format("2006-01-02")) - } - - joinClause := "" - userFilter := "" - if userID != nil { - joinClause = "LEFT JOIN letter_outgoing_recipients lor ON lor.letter_id = lo.id" - userFilter = fmt.Sprintf(" AND lor.user_id = '%s'", userID.String()) - } - - query = fmt.Sprintf(query, incomingDateFilter, joinClause, outgoingDateFilter+userFilter) - - if err := db.Raw(query).Scan(&results).Error; err != nil { - return nil, err - } - - return results, nil -} - -// GetPriorityDistribution gets letter distribution by priority -func (r *AnalyticsRepository) GetPriorityDistribution(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) { - db := DBFromContext(ctx, r.db) - var results []map[string]interface{} - - query := ` - SELECT - p.id as priority_id, - p.name as priority_name, - p.level, - COUNT(lo.id) as count, - ROUND(COUNT(lo.id) * 100.0 / SUM(COUNT(lo.id)) OVER (), 2) as percentage, - AVG(EXTRACT(EPOCH FROM (lo.updated_at - lo.created_at))/3600) as avg_response_time - FROM priorities p - LEFT JOIN letters_outgoing lo ON lo.priority_id = p.id AND lo.deleted_at IS NULL - WHERE 1=1 - %s - GROUP BY p.id, p.name, p.level - ORDER BY p.level ASC - ` - - dateFilter := "" - if !startDate.IsZero() { - dateFilter += fmt.Sprintf(" AND lo.created_at >= '%s'", startDate.Format("2006-01-02")) - } - if !endDate.IsZero() { - dateFilter += fmt.Sprintf(" AND lo.created_at <= '%s'", endDate.Format("2006-01-02")) - } - - query = fmt.Sprintf(query, dateFilter) - - if err := db.Raw(query).Scan(&results).Error; err != nil { - return nil, err - } - - return results, nil -} - -// GetDepartmentStats gets statistics per department using summary tables -func (r *AnalyticsRepository) GetDepartmentStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) { - db := DBFromContext(ctx, r.db) - var results []map[string]interface{} - - // Format tanggal untuk logging - log.Printf("GetDepartmentStats called with startDate: %v, endDate: %v", startDate, endDate) - - // Try summary table first - query := ` - SELECT - d.id as department_id, - d.name as department_name, - d.code as department_code, - COALESCE(SUM(dls.incoming_count), 0) as incoming_count, - COALESCE(SUM(dls.outgoing_count), 0) as outgoing_count, - COALESCE(SUM(dls.pending_outgoing), 0) as pending_count, - COALESCE(AVG(dls.avg_response_hours), 0) as avg_response_time, - COALESCE(AVG(dls.completion_rate), 0) as completion_rate - FROM departments d - LEFT JOIN department_letter_summary dls ON dls.department_id = d.id` - - var conditions []string - var args []interface{} - - if !startDate.IsZero() { - conditions = append(conditions, "dls.summary_date >= ?") - args = append(args, startDate.Format("2006-01-02")) - } - if !endDate.IsZero() { - conditions = append(conditions, "dls.summary_date <= ?") - args = append(args, endDate.Format("2006-01-02")) - } - - if len(conditions) > 0 { - query += " WHERE " + strings.Join(conditions, " AND ") - } - - query += ` - GROUP BY d.id, d.name, d.code - ORDER BY (COALESCE(SUM(dls.incoming_count), 0) + COALESCE(SUM(dls.outgoing_count), 0)) DESC` - - log.Printf("Summary query: %s, args: %v", query, args) - - if err := db.Raw(query, args...).Scan(&results).Error; err != nil { - return nil, err - } - - // Check if summary table has data for this period - var summaryCount int64 - checkQuery := "SELECT COUNT(*) FROM department_letter_summary WHERE 1=1" - checkArgs := []interface{}{} - - if !startDate.IsZero() { - checkQuery += " AND summary_date >= ?" - checkArgs = append(checkArgs, startDate.Format("2006-01-02")) - } - if !endDate.IsZero() { - checkQuery += " AND summary_date <= ?" - checkArgs = append(checkArgs, endDate.Format("2006-01-02")) - } - - db.Raw(checkQuery, checkArgs...).Scan(&summaryCount) - log.Printf("Summary count: %d", summaryCount) - - // If no summary data exists for this period, fall back to direct query - if summaryCount == 0 { - log.Println("Using fallback query (no summary data)") - - // Use CTE for better performance - fallbackQuery := ` - WITH filtered_incoming AS ( - SELECT - li.id, - li.created_at, - li.updated_at, - lir.recipient_department_id - FROM letters_incoming li - INNER JOIN letter_incoming_recipients lir ON lir.letter_id = li.id - WHERE li.deleted_at IS NULL` - - var fallbackArgs []interface{} - - if !startDate.IsZero() { - fallbackQuery += " AND li.created_at >= ?" - fallbackArgs = append(fallbackArgs, startDate) - } - if !endDate.IsZero() { - fallbackQuery += " AND li.created_at <= ?" - fallbackArgs = append(fallbackArgs, endDate) - } - - fallbackQuery += ` - ), - filtered_outgoing AS ( - SELECT - lo.id, - lo.status, - lo.created_at, - lo.updated_at, - lor.department_id - FROM letters_outgoing lo - INNER JOIN letter_outgoing_recipients lor ON lor.letter_id = lo.id - WHERE lo.deleted_at IS NULL` - - if !startDate.IsZero() { - fallbackQuery += " AND lo.created_at >= ?" - fallbackArgs = append(fallbackArgs, startDate) - } - if !endDate.IsZero() { - fallbackQuery += " AND lo.created_at <= ?" - fallbackArgs = append(fallbackArgs, endDate) - } - - fallbackQuery += ` - ) - SELECT - d.id as department_id, - d.name as department_name, - d.code as department_code, - COUNT(DISTINCT fi.id) as incoming_count, - COUNT(DISTINCT fo.id) as outgoing_count, - COUNT(DISTINCT CASE WHEN fo.status = 'pending_approval' THEN fo.id END) as pending_count, - COALESCE(AVG(CASE - WHEN fo.status IN ('approved', 'sent', 'archived') - THEN EXTRACT(EPOCH FROM (fo.updated_at - fo.created_at))/3600 - END), 0) as avg_response_time, - CASE - WHEN COUNT(DISTINCT fo.id) > 0 - THEN ROUND(COUNT(DISTINCT CASE WHEN fo.status IN ('sent', 'archived') THEN fo.id END) * 100.0 / COUNT(DISTINCT fo.id), 2) - ELSE 0 - END as completion_rate - FROM departments d - LEFT JOIN filtered_incoming fi ON fi.recipient_department_id = d.id - LEFT JOIN filtered_outgoing fo ON fo.department_id = d.id - GROUP BY d.id, d.name, d.code - ORDER BY (COUNT(DISTINCT fi.id) + COUNT(DISTINCT fo.id)) DESC` - - log.Printf("Fallback query: %s", fallbackQuery) - log.Printf("Fallback args: %v", fallbackArgs) - - if err := db.Raw(fallbackQuery, fallbackArgs...).Scan(&results).Error; err != nil { - return nil, err - } - } - - return results, nil -} - -// GetMonthlyTrend gets monthly trend data using summary tables for better performance -func (r *AnalyticsRepository) GetMonthlyTrend(ctx context.Context, months int) ([]map[string]interface{}, error) { - db := DBFromContext(ctx, r.db) - var results []map[string]interface{} - - // Use summary table for better performance - query := ` - WITH monthly_aggregated AS ( - SELECT - TO_CHAR(summary_date, 'Month') as month, - EXTRACT(YEAR FROM summary_date) as year, - EXTRACT(MONTH FROM summary_date) as month_num, - SUM(CASE WHEN letter_type = 'incoming' THEN total_count ELSE 0 END) as incoming_count, - SUM(CASE WHEN letter_type = 'outgoing' THEN total_count ELSE 0 END) as outgoing_count, - SUM(total_count) as total_count - FROM letter_summary - WHERE summary_date >= NOW() - INTERVAL '%d months' - GROUP BY TO_CHAR(summary_date, 'Month'), - EXTRACT(YEAR FROM summary_date), - EXTRACT(MONTH FROM summary_date) - ) - SELECT - month, - year, - incoming_count, - outgoing_count, - total_count, - LAG(total_count) OVER (ORDER BY year, month_num) as prev_total - FROM monthly_aggregated - ORDER BY year DESC, month_num DESC - LIMIT %d - ` - - query = fmt.Sprintf(query, months, months) - - if err := db.Raw(query).Scan(&results).Error; err != nil { - // If summary table is empty, fall back to direct query - if len(results) == 0 { - fallbackQuery := ` - WITH monthly_data AS ( - SELECT - TO_CHAR(date_trunc('month', created_at), 'Month') as month, - EXTRACT(YEAR FROM created_at) as year, - EXTRACT(MONTH FROM created_at) as month_num, - COUNT(*) as incoming_count, - 0 as outgoing_count - FROM letters_incoming - WHERE deleted_at IS NULL - AND created_at >= NOW() - INTERVAL '%d months' - GROUP BY date_trunc('month', created_at), EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at) - - UNION ALL - - SELECT - TO_CHAR(date_trunc('month', created_at), 'Month') as month, - EXTRACT(YEAR FROM created_at) as year, - EXTRACT(MONTH FROM created_at) as month_num, - 0 as incoming_count, - COUNT(*) as outgoing_count - FROM letters_outgoing - WHERE deleted_at IS NULL - AND created_at >= NOW() - INTERVAL '%d months' - GROUP BY date_trunc('month', created_at), EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at) - ) - SELECT - month, - year, - SUM(incoming_count) as incoming_count, - SUM(outgoing_count) as outgoing_count, - SUM(incoming_count + outgoing_count) as total_count, - LAG(SUM(incoming_count + outgoing_count)) OVER (ORDER BY year, month_num) as prev_total - FROM monthly_data - GROUP BY month, year, month_num - ORDER BY year DESC, month_num DESC - LIMIT %d - ` - - fallbackQuery = fmt.Sprintf(fallbackQuery, months, months, months) - - if err := db.Raw(fallbackQuery).Scan(&results).Error; err != nil { - return nil, err - } - } - } - - // Calculate growth rate - for i := range results { - if results[i]["prev_total"] != nil { - prevVal, ok := results[i]["prev_total"].(float64) - if ok && prevVal > 0 { - current := getFloat64FromInterface(results[i]["total_count"]) - results[i]["growth_rate"] = ((current - prevVal) / prevVal) * 100 - } else { - results[i]["growth_rate"] = float64(0) - } - } else { - results[i]["growth_rate"] = float64(0) - } - delete(results[i], "prev_total") - } - - return results, nil -} - -func (r *AnalyticsRepository) GetMonthlyTrendByUserID(ctx context.Context, userID *uuid.UUID, months int) ([]map[string]interface{}, error) { - db := DBFromContext(ctx, r.db) - var results []map[string]interface{} - - // Direct query (since we need to filter by user) - fallbackQuery := ` - WITH monthly_data AS ( - SELECT - TO_CHAR(date_trunc('month', li.created_at), 'Month') as month, - EXTRACT(YEAR FROM li.created_at) as year, - EXTRACT(MONTH FROM li.created_at) as month_num, - COUNT(DISTINCT li.id) as incoming_count, - 0 as outgoing_count - FROM letters_incoming li - INNER JOIN letter_incoming_recipients lir ON lir.letter_id = li.id - WHERE li.deleted_at IS NULL - AND lir.recipient_user_id = ? - AND li.created_at >= NOW() - INTERVAL '%d months' - GROUP BY date_trunc('month', li.created_at), EXTRACT(YEAR FROM li.created_at), EXTRACT(MONTH FROM li.created_at) - - UNION ALL - - SELECT - TO_CHAR(date_trunc('month', lo.created_at), 'Month') as month, - EXTRACT(YEAR FROM lo.created_at) as year, - EXTRACT(MONTH FROM lo.created_at) as month_num, - 0 as incoming_count, - COUNT(DISTINCT lo.id) as outgoing_count - FROM letters_outgoing lo - INNER JOIN letter_outgoing_recipients lor ON lor.letter_id = lo.id - WHERE lo.deleted_at IS NULL - AND lor.user_id = ? - AND lo.created_at >= NOW() - INTERVAL '%d months' - GROUP BY date_trunc('month', lo.created_at), EXTRACT(YEAR FROM lo.created_at), EXTRACT(MONTH FROM lo.created_at) - ) - SELECT - month, - year, - SUM(incoming_count) as incoming_count, - SUM(outgoing_count) as outgoing_count, - SUM(incoming_count + outgoing_count) as total_count, - LAG(SUM(incoming_count + outgoing_count)) OVER (ORDER BY year, month_num) as prev_total - FROM monthly_data - GROUP BY month, year, month_num - ORDER BY year DESC, month_num DESC - LIMIT %d - ` - - fallbackQuery = fmt.Sprintf(fallbackQuery, months, months, months) - - if err := db.Raw(fallbackQuery, userID, userID).Scan(&results).Error; err != nil { - return nil, err - } - - // Calculate growth rate - for i := range results { - if results[i]["prev_total"] != nil { - prevVal, ok := results[i]["prev_total"].(float64) - if ok && prevVal > 0 { - current := getFloat64FromInterface(results[i]["total_count"]) - results[i]["growth_rate"] = ((current - prevVal) / prevVal) * 100 - } else { - results[i]["growth_rate"] = float64(0) - } - } else { - results[i]["growth_rate"] = float64(0) - } - delete(results[i], "prev_total") - } - - return results, nil -} - -// Helper function to safely convert interface{} to float64 -func getFloat64FromInterface(v interface{}) float64 { - if v == nil { - return 0 - } - switch val := v.(type) { - case float64: - return val - case int64: - return float64(val) - case int: - return float64(val) - default: - return 0 - } -} - -// GetTopSenders gets top letter senders -func (r *AnalyticsRepository) GetTopSenders(ctx context.Context, limit int, startDate, endDate time.Time) ([]map[string]interface{}, error) { - db := DBFromContext(ctx, r.db) - var results []map[string]interface{} - - query := ` - SELECT - u.id as user_id, - u.name as user_name, - u.email as user_email, - COALESCE(d.name, 'No Department') as department, - COUNT(lo.id) as letter_count, - AVG(EXTRACT(EPOCH FROM (lo.updated_at - lo.created_at))/3600) as avg_response_time - FROM users u - LEFT JOIN letters_outgoing lo ON lo.created_by = u.id - LEFT JOIN user_department ud ON ud.user_id = u.id - LEFT JOIN departments d ON d.id = ud.department_id - WHERE lo.deleted_at IS NULL - %s - GROUP BY u.id, u.name, u.email, d.name - ORDER BY letter_count DESC - LIMIT %d - ` - - dateFilter := "" - if !startDate.IsZero() { - dateFilter += fmt.Sprintf(" AND lo.created_at >= '%s'", startDate.Format("2006-01-02")) - } - if !endDate.IsZero() { - dateFilter += fmt.Sprintf(" AND lo.created_at <= '%s'", endDate.Format("2006-01-02")) - } - - query = fmt.Sprintf(query, dateFilter, limit) - - if err := db.Raw(query).Scan(&results).Error; err != nil { - return nil, err - } - - return results, nil -} - -// GetInstitutionStats gets statistics per institution using summary tables -func (r *AnalyticsRepository) GetInstitutionStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) { - db := DBFromContext(ctx, r.db) - var results []map[string]interface{} - - // Use summary table for better performance - query := ` - SELECT - i.id as institution_id, - i.name as institution_name, - i.type as institution_type, - COALESCE(SUM(ils.incoming_sent), 0) as incoming_count, - COALESCE(SUM(ils.outgoing_received), 0) as outgoing_count, - COALESCE(SUM(ils.total_correspondence), 0) as total_count, - MAX(ils.last_activity_at) as last_activity - FROM institutions i - LEFT JOIN institution_letter_summary ils ON ils.institution_id = i.id - WHERE 1=1 - %s - GROUP BY i.id, i.name, i.type - HAVING COALESCE(SUM(ils.total_correspondence), 0) > 0 - ORDER BY total_count DESC - ` - - dateFilter := "" - if !startDate.IsZero() { - dateFilter += fmt.Sprintf(" AND ils.summary_date >= '%s'", startDate.Format("2006-01-02")) - } - if !endDate.IsZero() { - dateFilter += fmt.Sprintf(" AND ils.summary_date <= '%s'", endDate.Format("2006-01-02")) - } - - query = fmt.Sprintf(query, dateFilter) - - if err := db.Raw(query).Scan(&results).Error; err != nil { - return nil, err - } - - return results, nil -} - -// GetApprovalMetrics gets approval-related metrics using summary tables -func (r *AnalyticsRepository) GetApprovalMetrics(ctx context.Context, startDate, endDate time.Time) (map[string]interface{}, error) { - db := DBFromContext(ctx, r.db) - metrics := make(map[string]interface{}) - - // Use summary table for better performance - query := db.Table("approval_sla_summary") - - if !startDate.IsZero() { - query = query.Where("summary_date >= ?", startDate) - } - if !endDate.IsZero() { - query = query.Where("summary_date <= ?", endDate) - } - - var result struct { - TotalApprovals int64 `gorm:"column:total_approvals"` - ApprovedCount int64 `gorm:"column:approved_count"` - RejectedCount int64 `gorm:"column:rejected_count"` - PendingCount int64 `gorm:"column:pending_count"` - AvgApprovalHours float64 `gorm:"column:avg_approval_hours"` - AvgApprovalSteps float64 `gorm:"column:avg_approval_steps"` - SLACompliance float64 `gorm:"column:sla_compliance"` - WithinSLA int64 `gorm:"column:within_sla"` - ExceededSLA int64 `gorm:"column:exceeded_sla"` - } - - query.Select(` - COALESCE(SUM(total_approvals), 0) as total_approvals, - COALESCE(SUM(approved_count), 0) as approved_count, - COALESCE(SUM(rejected_count), 0) as rejected_count, - COALESCE(SUM(pending_count), 0) as pending_count, - COALESCE(AVG(avg_approval_hours), 0) as avg_approval_hours, - COALESCE(AVG(avg_approval_steps), 0) as avg_approval_steps, - COALESCE(AVG(sla_compliance_rate), 0) as sla_compliance, - COALESCE(SUM(within_sla_count), 0) as within_sla, - COALESCE(SUM(exceeded_sla_count), 0) as exceeded_sla - `).Scan(&result) - - metrics["total_submitted"] = result.TotalApprovals - metrics["total_approved"] = result.ApprovedCount - metrics["total_rejected"] = result.RejectedCount - metrics["total_pending"] = result.PendingCount - metrics["avg_approval_time"] = result.AvgApprovalHours - metrics["avg_approval_steps"] = result.AvgApprovalSteps - metrics["sla_compliance_rate"] = result.SLACompliance - metrics["within_sla_count"] = result.WithinSLA - metrics["exceeded_sla_count"] = result.ExceededSLA - - // Calculate rates - if result.TotalApprovals > 0 { - metrics["approval_rate"] = float64(result.ApprovedCount) / float64(result.TotalApprovals) * 100 - metrics["rejection_rate"] = float64(result.RejectedCount) / float64(result.TotalApprovals) * 100 - } else { - metrics["approval_rate"] = float64(0) - metrics["rejection_rate"] = float64(0) - } - - return metrics, nil -} - -// GetDailyActivity gets daily activity data -func (r *AnalyticsRepository) GetDailyActivity(ctx context.Context, days int) ([]map[string]interface{}, error) { - db := DBFromContext(ctx, r.db) - var results []map[string]interface{} - - query := ` - WITH daily_data AS ( - SELECT - DATE(created_at) as date, - TO_CHAR(created_at, 'Day') as day_of_week, - COUNT(CASE WHEN type = 'incoming' THEN 1 END) as incoming_count, - COUNT(CASE WHEN type = 'outgoing' THEN 1 END) as outgoing_count, - 0 as approved_count, - 0 as rejected_count - FROM ( - SELECT created_at, 'incoming' as type FROM letters_incoming WHERE deleted_at IS NULL - UNION ALL - SELECT created_at, 'outgoing' as type FROM letters_outgoing WHERE deleted_at IS NULL - ) combined - WHERE created_at >= CURRENT_DATE - INTERVAL '%d days' - GROUP BY DATE(created_at), TO_CHAR(created_at, 'Day') - ), - approval_data AS ( - SELECT - DATE(acted_at) as date, - COUNT(CASE WHEN status = 'approved' THEN 1 END) as approved_count, - COUNT(CASE WHEN status = 'rejected' THEN 1 END) as rejected_count - FROM letter_outgoing_approvals - WHERE acted_at IS NOT NULL - AND acted_at >= CURRENT_DATE - INTERVAL '%d days' - GROUP BY DATE(acted_at) - ) - SELECT - d.date, - d.day_of_week, - d.incoming_count, - d.outgoing_count, - COALESCE(a.approved_count, 0) as approved_count, - COALESCE(a.rejected_count, 0) as rejected_count - FROM daily_data d - LEFT JOIN approval_data a ON a.date = d.date - ORDER BY d.date DESC - LIMIT %d - ` - - query = fmt.Sprintf(query, days, days, days) - - if err := db.Raw(query).Scan(&results).Error; err != nil { - return nil, err - } - - return results, nil -} - -func (r *AnalyticsRepository) GetDailyActivityByUserID(ctx context.Context, userID *uuid.UUID, days int) ([]map[string]interface{}, error) { - db := DBFromContext(ctx, r.db) - var results []map[string]interface{} - - query := ` - WITH daily_data AS ( - SELECT - DATE(created_at) as date, - TO_CHAR(created_at, 'Day') as day_of_week, - COUNT(DISTINCT CASE WHEN type = 'incoming' THEN letter_id END) as incoming_count, - COUNT(DISTINCT CASE WHEN type = 'outgoing' THEN letter_id END) as outgoing_count, - 0 as approved_count, - 0 as rejected_count - FROM ( - SELECT li.id as letter_id, li.created_at, 'incoming' as type - FROM letters_incoming li - INNER JOIN letter_incoming_recipients lir ON lir.letter_id = li.id - WHERE li.deleted_at IS NULL AND lir.recipient_user_id = ? - UNION ALL - SELECT lo.id as letter_id, lo.created_at, 'outgoing' as type - FROM letters_outgoing lo - INNER JOIN letter_outgoing_recipients lor ON lor.letter_id = lo.id - WHERE lo.deleted_at IS NULL AND lor.user_id = ? - ) combined - WHERE created_at >= CURRENT_DATE - INTERVAL '%d days' - GROUP BY DATE(created_at), TO_CHAR(created_at, 'Day') - ), - approval_data AS ( - SELECT - DATE(acted_at) as date, - COUNT(CASE WHEN status = 'approved' THEN 1 END) as approved_count, - COUNT(CASE WHEN status = 'rejected' THEN 1 END) as rejected_count - FROM letter_outgoing_approvals - WHERE acted_at IS NOT NULL - AND acted_at >= CURRENT_DATE - INTERVAL '%d days' - AND approver_id = ? - GROUP BY DATE(acted_at) - ) - SELECT - d.date, - d.day_of_week, - d.incoming_count, - d.outgoing_count, - COALESCE(a.approved_count, 0) as approved_count, - COALESCE(a.rejected_count, 0) as rejected_count - FROM daily_data d - LEFT JOIN approval_data a ON a.date = d.date - ORDER BY d.date DESC - LIMIT %d - ` - - query = fmt.Sprintf(query, days, days, days) - - if err := db.Raw(query, userID, userID, userID).Scan(&results).Error; err != nil { - return nil, err - } - - return results, nil -} - -// GetResponseTimeStats gets response time statistics -func (r *AnalyticsRepository) GetResponseTimeStats(ctx context.Context, startDate, endDate time.Time) (map[string]interface{}, error) { - db := DBFromContext(ctx, r.db) - stats := make(map[string]interface{}) - - query := ` - WITH response_times AS ( - SELECT - EXTRACT(EPOCH FROM (updated_at - created_at))/3600 as response_time_hours - FROM letters_outgoing - WHERE status IN ('approved', 'sent', 'archived') - AND deleted_at IS NULL - %s - ) - SELECT - MIN(response_time_hours) as min_response_time, - MAX(response_time_hours) as max_response_time, - AVG(response_time_hours) as avg_response_time, - PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY response_time_hours) as median_response_time, - PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY response_time_hours) as p95_response_time, - PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY response_time_hours) as p99_response_time - FROM response_times - ` - - dateFilter := "" - if !startDate.IsZero() { - dateFilter += fmt.Sprintf(" AND created_at >= '%s'", startDate.Format("2006-01-02")) - } - if !endDate.IsZero() { - dateFilter += fmt.Sprintf(" AND created_at <= '%s'", endDate.Format("2006-01-02")) - } - - query = fmt.Sprintf(query, dateFilter) - - if err := db.Raw(query).Scan(&stats).Error; err != nil { - return nil, err - } - - return stats, nil -} diff --git a/internal/repository/app_setting_repository.go b/internal/repository/app_setting_repository.go deleted file mode 100644 index 5b17612..0000000 --- a/internal/repository/app_setting_repository.go +++ /dev/null @@ -1,62 +0,0 @@ -package repository - -import ( - "context" - "encoding/json" - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type AppSettingRepository struct{ db *gorm.DB } - -func NewAppSettingRepository(db *gorm.DB) *AppSettingRepository { - return &AppSettingRepository{ - db: db, - } -} - -func (r *AppSettingRepository) Get(ctx context.Context, key string) (*entities.AppSetting, error) { - db := DBFromContext(ctx, r.db) - var e entities.AppSetting - if err := db.WithContext(ctx).First(&e, "key = ?", key).Error; err != nil { - return nil, err - } - return &e, nil -} -func (r *AppSettingRepository) Upsert(ctx context.Context, key string, value entities.JSONB) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Exec("INSERT INTO app_settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP", key, value).Error -} - -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/approval_flow_repository.go b/internal/repository/approval_flow_repository.go deleted file mode 100644 index b4dd568..0000000 --- a/internal/repository/approval_flow_repository.go +++ /dev/null @@ -1,271 +0,0 @@ -package repository - -import ( - "context" - - "eslogad-be/internal/entities" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type ApprovalFlowRepository struct{ db *gorm.DB } - -func NewApprovalFlowRepository(db *gorm.DB) *ApprovalFlowRepository { - return &ApprovalFlowRepository{db: db} -} - -func (r *ApprovalFlowRepository) Create(ctx context.Context, e *entities.ApprovalFlow) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} - -func (r *ApprovalFlowRepository) Get(ctx context.Context, id uuid.UUID) (*entities.ApprovalFlow, error) { - db := DBFromContext(ctx, r.db) - var e entities.ApprovalFlow - if err := db.WithContext(ctx). - Preload("Department"). - Preload("Steps", func(db *gorm.DB) *gorm.DB { - return db.Order("step_order ASC, parallel_group ASC") - }). - Preload("Steps.ApproverRole"). - Preload("Steps.ApproverUser"). - Where("id = ?", id). - First(&e).Error; err != nil { - return nil, err - } - return &e, nil -} - -func (r *ApprovalFlowRepository) GetByDepartment(ctx context.Context, departmentID uuid.UUID) (*entities.ApprovalFlow, error) { - db := DBFromContext(ctx, r.db) - var e entities.ApprovalFlow - if err := db.WithContext(ctx). - Preload("Department"). - Preload("Steps", func(db *gorm.DB) *gorm.DB { - return db.Order("step_order ASC, parallel_group ASC") - }). - Preload("Steps.ApproverRole"). - Preload("Steps.ApproverUser"). - Where("department_id = ? AND is_active = true", departmentID). - First(&e).Error; err != nil { - return nil, err - } - return &e, nil -} - -func (r *ApprovalFlowRepository) Update(ctx context.Context, e *entities.ApprovalFlow) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Model(&entities.ApprovalFlow{}).Where("id = ?", e.ID).Updates(e).Error -} - -func (r *ApprovalFlowRepository) Delete(ctx context.Context, id uuid.UUID) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Where("id = ?", id).Delete(&entities.ApprovalFlow{}).Error -} - -type ListApprovalFlowsFilter struct { - DepartmentID *uuid.UUID - Search *string - IsActive *bool -} - -func (r *ApprovalFlowRepository) List(ctx context.Context, filter ListApprovalFlowsFilter, limit, offset int) ([]entities.ApprovalFlow, int64, error) { - var list []entities.ApprovalFlow - var total int64 - - // Build base query for counting - countQuery := r.db.WithContext(ctx).Model(&entities.ApprovalFlow{}) - - if filter.DepartmentID != nil { - countQuery = countQuery.Where("department_id = ?", *filter.DepartmentID) - } - - if filter.IsActive != nil { - countQuery = countQuery.Where("is_active = ?", *filter.IsActive) - } - - if filter.Search != nil && *filter.Search != "" { - like := "%" + *filter.Search + "%" - countQuery = countQuery.Where("name ILIKE ? OR description ILIKE ?", like, like) - } - - // Get total count - if err := countQuery.Count(&total).Error; err != nil { - return nil, 0, err - } - - // Build query for fetching data - BUAT QUERY BARU DARI AWAL - dataQuery := r.db.WithContext(ctx).Model(&entities.ApprovalFlow{}) - - if filter.DepartmentID != nil { - dataQuery = dataQuery.Where("department_id = ?", *filter.DepartmentID) - } - - if filter.IsActive != nil { - dataQuery = dataQuery.Where("is_active = ?", *filter.IsActive) - } - - if filter.Search != nil && *filter.Search != "" { - like := "%" + *filter.Search + "%" - dataQuery = dataQuery.Where("name ILIKE ? OR description ILIKE ?", like, like) - } - - // Fetch data with pagination and preloads - if err := dataQuery. - Order("created_at DESC"). - Limit(limit). - Offset(offset). - Preload("Department"). - Preload("Steps", func(db *gorm.DB) *gorm.DB { - return db.Order("step_order ASC, parallel_group ASC") - }). - Find(&list).Error; err != nil { - return nil, 0, err - } - - return list, total, nil -} - -type ApprovalFlowStepRepository struct{ db *gorm.DB } - -func NewApprovalFlowStepRepository(db *gorm.DB) *ApprovalFlowStepRepository { - return &ApprovalFlowStepRepository{db: db} -} - -func (r *ApprovalFlowStepRepository) Create(ctx context.Context, e *entities.ApprovalFlowStep) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} - -func (r *ApprovalFlowStepRepository) CreateBulk(ctx context.Context, list []entities.ApprovalFlowStep) error { - db := DBFromContext(ctx, r.db) - if len(list) == 0 { - return nil - } - return db.WithContext(ctx).Create(&list).Error -} - -func (r *ApprovalFlowStepRepository) Update(ctx context.Context, e *entities.ApprovalFlowStep) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Model(&entities.ApprovalFlowStep{}).Where("id = ?", e.ID).Updates(e).Error -} - -func (r *ApprovalFlowStepRepository) Delete(ctx context.Context, id uuid.UUID) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Where("id = ?", id).Delete(&entities.ApprovalFlowStep{}).Error -} - -func (r *ApprovalFlowStepRepository) DeleteByFlow(ctx context.Context, flowID uuid.UUID) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Where("flow_id = ?", flowID).Delete(&entities.ApprovalFlowStep{}).Error -} - -func (r *ApprovalFlowStepRepository) ListByFlow(ctx context.Context, flowID uuid.UUID) ([]entities.ApprovalFlowStep, error) { - db := DBFromContext(ctx, r.db) - var list []entities.ApprovalFlowStep - if err := db.WithContext(ctx). - Preload("ApproverRole"). - Preload("ApproverUser"). - Where("flow_id = ?", flowID). - Order("step_order ASC, parallel_group ASC"). - Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -type LetterOutgoingApprovalRepository struct{ db *gorm.DB } - -func NewLetterOutgoingApprovalRepository(db *gorm.DB) *LetterOutgoingApprovalRepository { - return &LetterOutgoingApprovalRepository{db: db} -} - -func (r *LetterOutgoingApprovalRepository) Create(ctx context.Context, e *entities.LetterOutgoingApproval) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} - -func (r *LetterOutgoingApprovalRepository) CreateBulk(ctx context.Context, list []entities.LetterOutgoingApproval) error { - db := DBFromContext(ctx, r.db) - if len(list) == 0 { - return nil - } - return db.WithContext(ctx).Create(&list).Error -} - -func (r *LetterOutgoingApprovalRepository) Get(ctx context.Context, id uuid.UUID) (*entities.LetterOutgoingApproval, error) { - db := DBFromContext(ctx, r.db) - var e entities.LetterOutgoingApproval - if err := db.WithContext(ctx). - Preload("Letter"). - Preload("Step"). - Preload("Approver"). - Where("id = ?", id). - First(&e).Error; err != nil { - return nil, err - } - return &e, nil -} - -func (r *LetterOutgoingApprovalRepository) GetByLetterAndStep(ctx context.Context, letterID, stepID uuid.UUID) (*entities.LetterOutgoingApproval, error) { - db := DBFromContext(ctx, r.db) - var e entities.LetterOutgoingApproval - if err := db.WithContext(ctx). - Where("letter_id = ? AND step_id = ?", letterID, stepID). - First(&e).Error; err != nil { - return nil, err - } - return &e, nil -} - -func (r *LetterOutgoingApprovalRepository) Update(ctx context.Context, e *entities.LetterOutgoingApproval) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Model(&entities.LetterOutgoingApproval{}).Where("id = ?", e.ID).Updates(e).Error -} - -func (r *LetterOutgoingApprovalRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingApproval, error) { - db := DBFromContext(ctx, r.db) - var list []entities.LetterOutgoingApproval - if err := db.WithContext(ctx). - Preload("Step.ApproverRole"). - Preload("Step.ApproverUser"). - Preload("Approver"). - Where("letter_id = ?", letterID). - Order("created_at ASC"). - Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -func (r *LetterOutgoingApprovalRepository) ListByLetterAndLasRevisionNumber(ctx context.Context, letterID uuid.UUID, revisionNumber int) ([]entities.LetterOutgoingApproval, error) { - db := DBFromContext(ctx, r.db) - var list []entities.LetterOutgoingApproval - if err := db.WithContext(ctx). - Preload("Step.ApproverRole"). - Preload("Step.ApproverUser"). - Preload("Approver"). - Where("letter_id = ? AND revision_number = ?", letterID, revisionNumber). - Order("created_at ASC"). - Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -func (r *LetterOutgoingApprovalRepository) GetPendingApprovals(ctx context.Context, userID uuid.UUID) ([]entities.LetterOutgoingApproval, error) { - db := DBFromContext(ctx, r.db) - var list []entities.LetterOutgoingApproval - - if err := db.WithContext(ctx). - Preload("Letter"). - Preload("Step"). - Joins("JOIN approval_flow_steps afs ON afs.id = letter_outgoing_approvals.step_id"). - Where("letter_outgoing_approvals.status = ? AND (afs.approver_user_id = ? OR afs.approver_role_id IN (SELECT role_id FROM user_roles WHERE user_id = ?))", - entities.ApprovalStatusPending, userID, userID). - Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} diff --git a/internal/repository/disposition_route_repository.go b/internal/repository/disposition_route_repository.go deleted file mode 100644 index c196e2c..0000000 --- a/internal/repository/disposition_route_repository.go +++ /dev/null @@ -1,212 +0,0 @@ -package repository - -import ( - "context" - - "eslogad-be/internal/entities" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type DispositionRouteRepository struct{ db *gorm.DB } - -func NewDispositionRouteRepository(db *gorm.DB) *DispositionRouteRepository { - return &DispositionRouteRepository{db: db} -} - -func (r *DispositionRouteRepository) Create(ctx context.Context, e *entities.DispositionRoute) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} - -// 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 - if err := db.WithContext(ctx). - Preload("FromDepartment"). - Preload("ToDepartment"). - First(&e, "id = ?", id).Error; err != nil { - return nil, err - } - return &e, nil -} -func (r *DispositionRouteRepository) ListByFromDept(ctx context.Context, fromDept uuid.UUID) ([]entities.DispositionRoute, error) { - db := DBFromContext(ctx, r.db) - var list []entities.DispositionRoute - if err := db.WithContext(ctx).Where("from_department_id = ? and is_active=true", fromDept). - Preload("FromDepartment"). - Preload("ToDepartment"). - Order("to_department_id").Find(&list).Error; err != nil { - return nil, err - } - 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/document_repository.go b/internal/repository/document_repository.go deleted file mode 100644 index 5e8301f..0000000 --- a/internal/repository/document_repository.go +++ /dev/null @@ -1,218 +0,0 @@ -package repository - -import ( - "context" - "eslogad-be/internal/entities" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -// DocumentSessionRepository handles document session operations -type DocumentSessionRepository struct { - db *gorm.DB -} - -func NewDocumentSessionRepository(db *gorm.DB) *DocumentSessionRepository { - return &DocumentSessionRepository{db: db} -} - -func (r *DocumentSessionRepository) Create(ctx context.Context, session *entities.DocumentSession) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(session).Error -} - -func (r *DocumentSessionRepository) GetByKey(ctx context.Context, documentKey string) (*entities.DocumentSession, error) { - db := DBFromContext(ctx, r.db) - var session entities.DocumentSession - err := db.WithContext(ctx). - Preload("User"). - Where("document_key = ?", documentKey). - First(&session).Error - if err != nil { - return nil, err - } - return &session, nil -} - -func (r *DocumentSessionRepository) GetActiveByDocument(ctx context.Context, documentID uuid.UUID) (*entities.DocumentSession, error) { - db := DBFromContext(ctx, r.db) - var session entities.DocumentSession - err := db.WithContext(ctx). - Preload("User"). - Where("document_id = ? AND status != 4", documentID). // Status 4 = closed - Order("created_at DESC"). - First(&session).Error - if err != nil { - return nil, err - } - return &session, nil -} - -func (r *DocumentSessionRepository) Update(ctx context.Context, session *entities.DocumentSession) error { - db := DBFromContext(ctx, r.db) - // Only update specific fields to avoid association issues - updates := map[string]interface{}{ - "status": session.Status, - "is_locked": session.IsLocked, - "locked_by": session.LockedBy, - "locked_at": session.LockedAt, - "last_saved_at": session.LastSavedAt, - "version": session.Version, - "updated_at": session.UpdatedAt, - } - return db.WithContext(ctx).Model(&entities.DocumentSession{}).Where("id = ?", session.ID).Updates(updates).Error -} - -func (r *DocumentSessionRepository) ListByDocument(ctx context.Context, documentID uuid.UUID) ([]entities.DocumentSession, error) { - db := DBFromContext(ctx, r.db) - var sessions []entities.DocumentSession - err := db.WithContext(ctx). - Preload("User"). - Where("document_id = ?", documentID). - Order("created_at DESC"). - Find(&sessions).Error - return sessions, err -} - -// DocumentVersionRepository handles document version operations -type DocumentVersionRepository struct { - db *gorm.DB -} - -func NewDocumentVersionRepository(db *gorm.DB) *DocumentVersionRepository { - return &DocumentVersionRepository{db: db} -} - -func (r *DocumentVersionRepository) Create(ctx context.Context, version *entities.DocumentVersion) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(version).Error -} - -func (r *DocumentVersionRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.DocumentVersion, error) { - db := DBFromContext(ctx, r.db) - var version entities.DocumentVersion - err := db.WithContext(ctx). - Preload("User"). - Where("id = ?", id). - First(&version).Error - if err != nil { - return nil, err - } - return &version, nil -} - -func (r *DocumentVersionRepository) GetActiveVersion(ctx context.Context, documentID uuid.UUID) (*entities.DocumentVersion, error) { - db := DBFromContext(ctx, r.db) - var version entities.DocumentVersion - err := db.WithContext(ctx). - Preload("User"). - Where("document_id = ? AND is_active = ?", documentID, true). - First(&version).Error - if err != nil { - return nil, err - } - return &version, nil -} - -func (r *DocumentVersionRepository) ListByDocument(ctx context.Context, documentID uuid.UUID) ([]entities.DocumentVersion, error) { - db := DBFromContext(ctx, r.db) - var versions []entities.DocumentVersion - err := db.WithContext(ctx). - Preload("User"). - Where("document_id = ?", documentID). - Order("version DESC"). - Find(&versions).Error - return versions, err -} - -func (r *DocumentVersionRepository) DeactivateAllVersions(ctx context.Context, documentID uuid.UUID) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx). - Model(&entities.DocumentVersion{}). - Where("document_id = ?", documentID). - Update("is_active", false).Error -} - -// DocumentMetadataRepository handles document metadata operations -type DocumentMetadataRepository struct { - db *gorm.DB -} - -func NewDocumentMetadataRepository(db *gorm.DB) *DocumentMetadataRepository { - return &DocumentMetadataRepository{db: db} -} - -func (r *DocumentMetadataRepository) Create(ctx context.Context, metadata *entities.DocumentMetadata) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(metadata).Error -} - -func (r *DocumentMetadataRepository) GetByDocumentID(ctx context.Context, documentID uuid.UUID) (*entities.DocumentMetadata, error) { - db := DBFromContext(ctx, r.db) - var metadata entities.DocumentMetadata - err := db.WithContext(ctx). - Where("document_id = ?", documentID). - First(&metadata).Error - if err != nil { - return nil, err - } - return &metadata, nil -} - -func (r *DocumentMetadataRepository) Update(ctx context.Context, metadata *entities.DocumentMetadata) error { - db := DBFromContext(ctx, r.db) - // Only update specific fields to avoid association issues - updates := map[string]interface{}{ - "document_type": metadata.DocumentType, - "reference_id": metadata.ReferenceID, - "file_name": metadata.FileName, - "file_type": metadata.FileType, - "file_size": metadata.FileSize, - "mime_type": metadata.MimeType, - "updated_at": metadata.UpdatedAt, - } - return db.WithContext(ctx).Model(&entities.DocumentMetadata{}).Where("id = ?", metadata.ID).Updates(updates).Error -} - -func (r *DocumentMetadataRepository) Delete(ctx context.Context, documentID uuid.UUID) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx). - Where("document_id = ?", documentID). - Delete(&entities.DocumentMetadata{}).Error -} - -// DocumentErrorRepository handles document error logging -type DocumentErrorRepository struct { - db *gorm.DB -} - -func NewDocumentErrorRepository(db *gorm.DB) *DocumentErrorRepository { - return &DocumentErrorRepository{db: db} -} - -func (r *DocumentErrorRepository) Create(ctx context.Context, docError *entities.DocumentError) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(docError).Error -} - -func (r *DocumentErrorRepository) ListByDocument(ctx context.Context, documentID uuid.UUID) ([]entities.DocumentError, error) { - db := DBFromContext(ctx, r.db) - var errors []entities.DocumentError - err := db.WithContext(ctx). - Preload("Session"). - Where("document_id = ?", documentID). - Order("created_at DESC"). - Find(&errors).Error - return errors, err -} - -func (r *DocumentErrorRepository) ListBySession(ctx context.Context, sessionID uuid.UUID) ([]entities.DocumentError, error) { - db := DBFromContext(ctx, r.db) - var errors []entities.DocumentError - err := db.WithContext(ctx). - Where("session_id = ?", sessionID). - Order("created_at DESC"). - Find(&errors).Error - return errors, err -} \ No newline at end of file diff --git a/internal/repository/letter_outgoing_repository.go b/internal/repository/letter_outgoing_repository.go deleted file mode 100644 index 91294a0..0000000 --- a/internal/repository/letter_outgoing_repository.go +++ /dev/null @@ -1,818 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "time" - - "eslogad-be/internal/entities" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type LetterOutgoingRepository struct{ db *gorm.DB } - -func NewLetterOutgoingRepository(db *gorm.DB) *LetterOutgoingRepository { - return &LetterOutgoingRepository{db: db} -} - -func (r *LetterOutgoingRepository) Create(ctx context.Context, e *entities.LetterOutgoing) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} - -func (r *LetterOutgoingRepository) Get(ctx context.Context, id uuid.UUID) (*entities.LetterOutgoing, error) { - db := DBFromContext(ctx, r.db) - var e entities.LetterOutgoing - if err := db.WithContext(ctx). - Preload("Priority"). - Preload("ReceiverInstitution"). - Preload("Creator"). - Preload("ApprovalFlow"). - Preload("Recipients"). - Preload("Attachments"). - Preload("FinalAttachments"). - Preload("Approvals.Step"). - Preload("Approvals.Approver"). - Where("id = ? AND deleted_at IS NULL", id). - First(&e).Error; err != nil { - return nil, err - } - return &e, nil -} - -func (r *LetterOutgoingRepository) GetByReferenceNumber(ctx context.Context, refNumber *string) (*entities.LetterOutgoing, error) { - db := DBFromContext(ctx, r.db) - var e entities.LetterOutgoing - if err := db.WithContext(ctx). - Preload("Priority"). - Preload("ReceiverInstitution"). - Preload("Creator"). - Preload("ApprovalFlow"). - Preload("Recipients"). - Preload("Attachments"). - Preload("FinalAttachments"). - Preload("Approvals.Step"). - Preload("Approvals.Approver"). - Where("reference_number = ? AND deleted_at IS NULL", refNumber). - First(&e).Error; err != nil { - return nil, err - } - return &e, nil -} - -func (r *LetterOutgoingRepository) Update(ctx context.Context, e *entities.LetterOutgoing) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("id = ? AND deleted_at IS NULL", e.ID).Updates(e).Error -} - -func (r *LetterOutgoingRepository) SoftDelete(ctx context.Context, id uuid.UUID) error { - db := DBFromContext(ctx, r.db) - now := time.Now() - return db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("id = ? AND deleted_at IS NULL", id).Update("deleted_at", now).Error -} - -func (r *LetterOutgoingRepository) BulkSoftDelete(ctx context.Context, ids []uuid.UUID) error { - if len(ids) == 0 { - return nil - } - - db := DBFromContext(ctx, r.db) - now := time.Now() - return db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("id IN ? AND deleted_at IS NULL", ids).Update("deleted_at", now).Error -} - -func (r *LetterOutgoingRepository) BulkArchive(ctx context.Context, letterIDs []uuid.UUID) (int64, error) { - db := DBFromContext(ctx, r.db) - now := time.Now() - result := db.WithContext(ctx). - Model(&entities.LetterOutgoing{}). - Where("id IN ? AND deleted_at IS NULL", letterIDs). - Updates(map[string]interface{}{ - "is_archived": true, - "archived_at": now, - }) - return result.RowsAffected, result.Error -} - -func (r *LetterOutgoingRepository) Archive(ctx context.Context, letterID uuid.UUID) error { - db := DBFromContext(ctx, r.db) - now := time.Now() - return db.WithContext(ctx). - Model(&entities.LetterOutgoing{}). - Where("id = ? AND deleted_at IS NULL", letterID). - Updates(map[string]interface{}{ - "is_archived": true, - "archived_at": now, - }).Error -} - -func (r *LetterOutgoingRepository) 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.LetterOutgoingRecipient{}). - Where("letter_id IN ? AND user_id = ?", letterIDs, userID). - Update("is_archived", true) - 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) - - // Preload all specified relations - for _, relation := range relations { - query = query.Preload(relation) - } - - var e entities.LetterOutgoing - if err := query.First(&e).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, gorm.ErrRecordNotFound - } - return nil, err - } - - return &e, nil -} - -type ListOutgoingLettersFilter struct { - Status *string - Query *string - CreatedBy *uuid.UUID - DepartmentID *uuid.UUID - UserID *uuid.UUID - ReceiverInstitutionID *uuid.UUID - FromDate *time.Time - ToDate *time.Time - PriorityID *uuid.UUID - PriorityIDs []uuid.UUID - SortBy *string - SortOrder *string - IsArchived *bool - IsRead *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("letter_outgoing_recipients.is_archived = ?", true) - } else { - query = query.Where("letter_outgoing_recipients.is_archived = ? OR letter_outgoing_recipients.is_archived IS NULL", false) - } - } - - if filter.Query != nil { - q := "%" + *filter.Query + "%" - query = query.Where("subject ILIKE ? OR reference_number ILIKE ? OR letter_number ILIKE ?", q, q, q) - } - if filter.CreatedBy != nil { - query = query.Where("created_by = ?", *filter.CreatedBy) - } - // Filter by UserID through recipients - if filter.UserID != nil { - query = query.Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id") - query = query.Where("letter_outgoing_recipients.user_id = ?", *filter.UserID) - - fmt.Printf("[DEBUG] filter.UserID: %v\n", filter.UserID) - fmt.Printf("[DEBUG] filter.isRead: %v\n", filter.IsRead) - - // Tambahkan filter IsRead - if filter.IsRead != nil { - if *filter.IsRead { - query = query.Where("letter_outgoing_recipients.read_at IS NOT NULL") - } else { - query = query.Where("letter_outgoing_recipients.read_at IS NULL") - } - } - - } - - if filter.Status != nil { - query = query.Joins("LEFT JOIN letter_outgoing_approvals ON letter_outgoing_approvals.letter_id = letters_outgoing.id") - query = query.Where("letter_outgoing_approvals.approver_id = ?", *filter.UserID) - - query = query.Where("letter_outgoing_approvals.status = ?", *filter.Status) - - query = query.Distinct() - } - - if filter.ReceiverInstitutionID != nil { - query = query.Where("receiver_institution_id = ?", *filter.ReceiverInstitutionID) - } - if filter.PriorityID != nil { - query = query.Where("priority_id = ?", *filter.PriorityID) - } - if len(filter.PriorityIDs) > 0 { - query = query.Where("priority_id IN ?", filter.PriorityIDs) - } - fmt.Printf("Priority %s", filter.PriorityIDs) - if filter.FromDate != nil { - query = query.Where("issue_date >= ?", *filter.FromDate) - } - if filter.ToDate != nil { - query = query.Where("issue_date <= ?", *filter.ToDate) - } - - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - - orderBy := "created_at DESC" // default - if filter.SortBy != nil { - sortField := *filter.SortBy - sortDirection := "ASC" - if filter.SortOrder != nil && (*filter.SortOrder == "desc" || *filter.SortOrder == "DESC") { - sortDirection = "DESC" - } - - switch sortField { - case "letter_number": - orderBy = "letter_number " + sortDirection - case "subject": - orderBy = "subject " + sortDirection - case "issue_date": - orderBy = "issue_date " + sortDirection - case "status": - orderBy = "status " + sortDirection - case "created_at": - orderBy = "created_at " + sortDirection - default: - orderBy = "created_at " + sortDirection - } - } - - var list []entities.LetterOutgoing - if err := query. - Preload("Priority"). - Preload("ReceiverInstitution"). - Preload("Creator"). - Preload("Creator.Profile"). - Preload("Creator.Departments"). - Preload("Recipients"). - Preload("Recipients.User"). - Preload("Recipients.Department"). - Preload("Attachments"). - Preload("FinalAttachments"). - Preload("Approvals.Step"). - Preload("Approvals.Approver"). - Order(orderBy). - Limit(limit). - Offset(offset). - Find(&list).Error; err != nil { - return nil, 0, err - } - return list, total, nil -} - -func (r *LetterOutgoingRepository) ListAll(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 search query filter - if filter.Query != nil { - q := "%" + *filter.Query + "%" - query = query.Where("subject ILIKE ? OR reference_number ILIKE ? OR letter_number ILIKE ?", q, q, q) - } - - // Filter by creator (if admin wants to see letters from specific creator) - if filter.CreatedBy != nil { - query = query.Where("created_by = ?", *filter.CreatedBy) - } - - // Filter by receiver institution - if filter.ReceiverInstitutionID != nil { - query = query.Where("receiver_institution_id = ?", *filter.ReceiverInstitutionID) - } - - // Filter by priority - if filter.PriorityID != nil { - query = query.Where("priority_id = ?", *filter.PriorityID) - } - - // Filter by multiple priorities - if len(filter.PriorityIDs) > 0 { - query = query.Where("priority_id IN ?", filter.PriorityIDs) - } - - // Date range filters - if filter.FromDate != nil { - query = query.Where("issue_date >= ?", *filter.FromDate) - } - if filter.ToDate != nil { - query = query.Where("issue_date <= ?", *filter.ToDate) - } - - // Filter by approval status (if admin wants to see letters with specific approval status) - // Note: This is different from user-specific approval status - if filter.Status != nil { - query = query.Joins("LEFT JOIN letter_outgoing_approvals ON letter_outgoing_approvals.letter_id = letters_outgoing.id"). - Where("letter_outgoing_approvals.status = ?", *filter.Status). - Distinct() - } - - // Get total count - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - - // Prepare sorting - orderBy := "created_at DESC" // default - if filter.SortBy != nil { - sortField := *filter.SortBy - sortDirection := "ASC" - if filter.SortOrder != nil && (*filter.SortOrder == "desc" || *filter.SortOrder == "DESC") { - sortDirection = "DESC" - } - - switch sortField { - case "letter_number": - orderBy = "letter_number " + sortDirection - case "subject": - orderBy = "subject " + sortDirection - case "issue_date": - orderBy = "issue_date " + sortDirection - case "status": - orderBy = "status " + sortDirection - case "created_at": - orderBy = "created_at " + sortDirection - default: - orderBy = "created_at " + sortDirection - } - } - - // Get paginated data with all relations - var list []entities.LetterOutgoing - if err := query. - Preload("Priority"). - Preload("ReceiverInstitution"). - Preload("Creator"). - Preload("Creator.Profile"). - Preload("Creator.Departments"). - Preload("Recipients"). - Preload("Recipients.User"). - Preload("Recipients.Department"). - Preload("Attachments"). - Preload("FinalAttachments"). - Preload("Approvals"). - Preload("Approvals.Step"). - Preload("Approvals.Approver"). - Order(orderBy). - Limit(limit). - Offset(offset). - Find(&list).Error; err != nil { - return nil, 0, err - } - - return list, total, nil -} - -func (r *LetterOutgoingRepository) Search(ctx context.Context, filters map[string]interface{}, limit, offset int, sortBy, sortOrder string) ([]entities.LetterOutgoing, int64, error) { - db := DBFromContext(ctx, r.db) - query := db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("deleted_at IS NULL") - - // Apply search filters - if q, ok := filters["query"]; ok && q != "" { - searchTerm := "%" + q.(string) + "%" - query = query.Where("subject ILIKE ? OR reference_number ILIKE ? OR letter_number ILIKE ? OR description ILIKE ? OR receiver_name ILIKE ?", searchTerm, searchTerm, searchTerm, searchTerm, searchTerm) - } - - if letterNumber, ok := filters["letter_number"]; ok && letterNumber != "" { - query = query.Where("letter_number ILIKE ?", "%"+letterNumber.(string)+"%") - } - - if subject, ok := filters["subject"]; ok && subject != "" { - query = query.Where("subject ILIKE ?", "%"+subject.(string)+"%") - } - - if status, ok := filters["status"]; ok && status != "" { - query = query.Where("status = ?", status) - } - - if priorityID, ok := filters["priority_id"]; ok { - query = query.Where("priority_id = ?", priorityID) - } - - if institutionID, ok := filters["receiver_institution_id"]; ok { - query = query.Where("receiver_institution_id = ?", institutionID) - } - - if createdBy, ok := filters["created_by"]; ok { - query = query.Where("created_by = ?", createdBy) - } - - if dateFrom, ok := filters["date_from"]; ok { - query = query.Where("issue_date >= ?", dateFrom) - } - - if dateTo, ok := filters["date_to"]; ok { - query = query.Where("issue_date <= ?", dateTo) - } - - // Apply user context filters if present - if userContext, ok := filters["user_context"]; ok { - if ctx, ok := userContext.(map[string]interface{}); ok { - if userID, ok := ctx["user_id"]; ok { - // User can see: letters created by them OR letters where they are recipients - subQuery := db.Model(&entities.LetterOutgoingRecipient{}).Select("letter_id").Where("user_id = ?", userID) - query = query.Where("created_by = ? OR id IN (?)", userID, subQuery) - } - } - } - - // Count total results - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - - // Apply sorting - if sortBy == "" { - sortBy = "created_at" - } - if sortOrder == "" { - sortOrder = "desc" - } - - validSortFields := map[string]bool{ - "letter_number": true, - "subject": true, - "issue_date": true, - "status": true, - "created_at": true, - "updated_at": true, - } - - if !validSortFields[sortBy] { - sortBy = "created_at" - } - - if sortOrder != "asc" && sortOrder != "desc" { - sortOrder = "desc" - } - - orderBy := sortBy + " " + sortOrder - - // Execute query with preloads - var letters []entities.LetterOutgoing - if err := query. - Preload("Priority"). - Preload("ReceiverInstitution"). - Preload("Creator"). - Preload("Creator.Profile"). - Order(orderBy). - Limit(limit). - Offset(offset). - Find(&letters).Error; err != nil { - return nil, 0, err - } - - return letters, total, nil -} - -func (r *LetterOutgoingRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status entities.LetterOutgoingStatus) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("id = ? AND deleted_at IS NULL", id).Update("status", status).Error -} - -type LetterOutgoingAttachmentRepository struct{ db *gorm.DB } - -func NewLetterOutgoingAttachmentRepository(db *gorm.DB) *LetterOutgoingAttachmentRepository { - return &LetterOutgoingAttachmentRepository{db: db} -} - -func (r *LetterOutgoingAttachmentRepository) Create(ctx context.Context, e *entities.LetterOutgoingAttachment) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} - -func (r *LetterOutgoingAttachmentRepository) CreateBulk(ctx context.Context, list []entities.LetterOutgoingAttachment) error { - db := DBFromContext(ctx, r.db) - if len(list) == 0 { - return nil - } - return db.WithContext(ctx).Create(&list).Error -} - -func (r *LetterOutgoingAttachmentRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingAttachment, error) { - db := DBFromContext(ctx, r.db) - var list []entities.LetterOutgoingAttachment - if err := db.WithContext(ctx).Where("letter_id = ?", letterID).Order("uploaded_at ASC").Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -func (r *LetterOutgoingAttachmentRepository) Delete(ctx context.Context, id uuid.UUID) error { - db := DBFromContext(ctx, r.db) - 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 LetterOutgoingFinalAttachmentRepository struct{ db *gorm.DB } - -func NewLetterOutgoingFinalAttachmentRepository(db *gorm.DB) *LetterOutgoingFinalAttachmentRepository { - return &LetterOutgoingFinalAttachmentRepository{db: db} -} - -func (r *LetterOutgoingFinalAttachmentRepository) Create(ctx context.Context, e *entities.LetterOutgoingFinalAttachment) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} - -func (r *LetterOutgoingFinalAttachmentRepository) CreateBulk(ctx context.Context, list []entities.LetterOutgoingFinalAttachment) error { - db := DBFromContext(ctx, r.db) - if len(list) == 0 { - return nil - } - return db.WithContext(ctx).Create(&list).Error -} - -func (r *LetterOutgoingFinalAttachmentRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingFinalAttachment, error) { - db := DBFromContext(ctx, r.db) - var list []entities.LetterOutgoingFinalAttachment - if err := db.WithContext(ctx).Where("letter_id = ?", letterID).Order("uploaded_at ASC").Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -func (r *LetterOutgoingFinalAttachmentRepository) Delete(ctx context.Context, id uuid.UUID) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Where("id = ?", id).Delete(&entities.LetterOutgoingAttachment{}).Error -} - -// ListByLetterIDs fetches attachments for multiple letters in a single query -func (r *LetterOutgoingFinalAttachmentRepository) ListByLetterIDs(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterOutgoingFinalAttachment, error) { - if len(letterIDs) == 0 { - return make(map[uuid.UUID][]entities.LetterOutgoingFinalAttachment), nil - } - - db := DBFromContext(ctx, r.db) - var attachments []entities.LetterOutgoingFinalAttachment - 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.LetterOutgoingFinalAttachment) - 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 { - return &LetterOutgoingRecipientRepository{db: db} -} - -func (r *LetterOutgoingRecipientRepository) Create(ctx context.Context, e *entities.LetterOutgoingRecipient) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} - -func (r *LetterOutgoingRecipientRepository) CreateBulk(ctx context.Context, list []entities.LetterOutgoingRecipient) error { - db := DBFromContext(ctx, r.db) - if len(list) == 0 { - return nil - } - 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 - - sql := ` - WITH valid_recipients AS ( - SELECT lor.id, lor.letter_id, lor.read_at, lor.created_at, - ROW_NUMBER() OVER (PARTITION BY lor.letter_id ORDER BY lor.created_at DESC) as rn - FROM letter_outgoing_recipients lor - INNER JOIN letters_outgoing l ON l.id = lor.letter_id AND l.deleted_at IS NULL - WHERE lor.user_id = ? - ) - SELECT COUNT(*) - FROM valid_recipients - WHERE rn = 1 AND read_at IS NULL - ` - - if err := db.WithContext(ctx).Raw(sql, userID).Scan(&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 - if err := db.WithContext(ctx). - Preload("User"). - Preload("Department"). - Where("letter_id = ?", letterID). - Order("is_primary DESC, created_at ASC"). - Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -func (r *LetterOutgoingRecipientRepository) Update(ctx context.Context, e *entities.LetterOutgoingRecipient) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Model(&entities.LetterOutgoingRecipient{}).Where("id = ?", e.ID).Updates(e).Error -} - -func (r *LetterOutgoingRecipientRepository) Delete(ctx context.Context, id uuid.UUID) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Where("id = ?", id).Delete(&entities.LetterOutgoingRecipient{}).Error -} - -func (r *LetterOutgoingRecipientRepository) DeleteByLetter(ctx context.Context, letterID uuid.UUID) error { - db := DBFromContext(ctx, r.db) - 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 { - return &LetterOutgoingDiscussionRepository{db: db} -} - -func (r *LetterOutgoingDiscussionRepository) Create(ctx context.Context, e *entities.LetterOutgoingDiscussion) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} - -func (r *LetterOutgoingDiscussionRepository) Get(ctx context.Context, id uuid.UUID) (*entities.LetterOutgoingDiscussion, error) { - db := DBFromContext(ctx, r.db) - var e entities.LetterOutgoingDiscussion - if err := db.WithContext(ctx). - Preload("User"). - Preload("Attachments"). - Where("id = ?", id). - First(&e).Error; err != nil { - return nil, err - } - return &e, nil -} - -func (r *LetterOutgoingDiscussionRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingDiscussion, error) { - db := DBFromContext(ctx, r.db) - var list []entities.LetterOutgoingDiscussion - if err := db.WithContext(ctx). - Preload("User"). - Preload("Attachments"). - Preload("Replies.User"). - Where("letter_id = ? AND parent_id IS NULL", letterID). - Order("created_at DESC"). - Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -func (r *LetterOutgoingRecipientRepository) GetByLetterIDsAndUser(ctx context.Context, letterIDs []uuid.UUID, userID 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). - Where(`id IN ( - SELECT id FROM ( - SELECT id, ROW_NUMBER() OVER (PARTITION BY letter_id ORDER BY created_at DESC) as rn - FROM letter_outgoing_recipients - WHERE letter_id IN ? AND user_id = ? - ) t WHERE rn = 1 - )`, letterIDs, userID). - Find(&recipients).Error; err != nil { - return nil, err - } - - result := make(map[uuid.UUID]*entities.LetterOutgoingRecipient) - for i := range recipients { - result[recipients[i].LetterID] = &recipients[i] - } - - return result, nil -} - -func (r *LetterOutgoingDiscussionRepository) Update(ctx context.Context, e *entities.LetterOutgoingDiscussion) error { - db := DBFromContext(ctx, r.db) - now := time.Now() - e.EditedAt = &now - return db.WithContext(ctx).Model(&entities.LetterOutgoingDiscussion{}).Where("id = ?", e.ID).Updates(e).Error -} - -func (r *LetterOutgoingDiscussionRepository) Delete(ctx context.Context, id uuid.UUID) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Where("id = ?", id).Delete(&entities.LetterOutgoingDiscussion{}).Error -} - -type LetterOutgoingDiscussionAttachmentRepository struct{ db *gorm.DB } - -func NewLetterOutgoingDiscussionAttachmentRepository(db *gorm.DB) *LetterOutgoingDiscussionAttachmentRepository { - return &LetterOutgoingDiscussionAttachmentRepository{db: db} -} - -func (r *LetterOutgoingDiscussionAttachmentRepository) CreateBulk(ctx context.Context, list []entities.LetterOutgoingDiscussionAttachment) error { - db := DBFromContext(ctx, r.db) - if len(list) == 0 { - return nil - } - return db.WithContext(ctx).Create(&list).Error -} - -type LetterOutgoingActivityLogRepository struct{ db *gorm.DB } - -func NewLetterOutgoingActivityLogRepository(db *gorm.DB) *LetterOutgoingActivityLogRepository { - return &LetterOutgoingActivityLogRepository{db: db} -} - -func (r *LetterOutgoingActivityLogRepository) Create(ctx context.Context, e *entities.LetterOutgoingActivityLog) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} - -func (r *LetterOutgoingActivityLogRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingActivityLog, error) { - db := DBFromContext(ctx, r.db) - var list []entities.LetterOutgoingActivityLog - if err := db.WithContext(ctx). - Preload("ActorUser"). - Preload("ActorDepartment"). - Where("letter_id = ?", letterID). - Order("occurred_at DESC"). - Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} diff --git a/internal/repository/letter_repository.go b/internal/repository/letter_repository.go deleted file mode 100644 index bc6bc33..0000000 --- a/internal/repository/letter_repository.go +++ /dev/null @@ -1,991 +0,0 @@ -package repository - -import ( - "context" - "time" - - "eslogad-be/internal/entities" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type LetterIncomingRepository struct{ db *gorm.DB } - -func NewLetterIncomingRepository(db *gorm.DB) *LetterIncomingRepository { - return &LetterIncomingRepository{db: db} -} - -func (r *LetterIncomingRepository) Create(ctx context.Context, e *entities.LetterIncoming) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} -func (r *LetterIncomingRepository) Get(ctx context.Context, id uuid.UUID) (*entities.LetterIncoming, error) { - db := DBFromContext(ctx, r.db) - var e entities.LetterIncoming - if err := db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil { - return nil, err - } - return &e, nil -} - -func (r *LetterIncomingRepository) GetByReferenceNumber(ctx context.Context, refNumber *string) (*entities.LetterIncoming, error) { - db := DBFromContext(ctx, r.db) - var e entities.LetterIncoming - if err := db.WithContext(ctx). - Where("reference_number = ? AND deleted_at IS NULL", refNumber). - First(&e).Error; err != nil { - return nil, err - } - 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 -} - -func (r *LetterIncomingRepository) SoftDelete(ctx context.Context, id uuid.UUID) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Exec("UPDATE letters_incoming SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL", id).Error -} - -func (r *LetterIncomingRepository) BulkSoftDelete(ctx context.Context, ids []uuid.UUID) error { - if len(ids) == 0 { - return nil - } - - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Exec("UPDATE letters_incoming SET deleted_at = CURRENT_TIMESTAMP WHERE id IN ? AND deleted_at IS NULL", ids).Error -} - -func (r *LetterIncomingRepository) BulkArchive(ctx context.Context, letterIDs []uuid.UUID) (int64, error) { - db := DBFromContext(ctx, r.db) - now := time.Now() - result := db.WithContext(ctx). - Model(&entities.LetterIncoming{}). - Where("id IN ? AND deleted_at IS NULL", letterIDs). - Updates(map[string]interface{}{ - "is_archived": true, - "archived_at": now, - }) - return result.RowsAffected, result.Error -} - -func (r *LetterIncomingRepository) Archive(ctx context.Context, letterID uuid.UUID) error { - db := DBFromContext(ctx, r.db) - now := time.Now() - return db.WithContext(ctx). - Model(&entities.LetterIncoming{}). - Where("id = ? AND deleted_at IS NULL", letterID). - Updates(map[string]interface{}{ - "is_archived": true, - "archived_at": now, - }).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 - 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 - } - - 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") - } - } - - 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 { - query = query.Where("lidd.id IS NOT NULL AND lidd.status != 'pending'") - } else { - query = query.Where("lidd.id IS NULL OR lidd.status = 'pending'") - } - } - - if len(filter.PriorityIDs) > 0 { - query = query.Where("letters_incoming.priority_id IN ?", filter.PriorityIDs) - } - - 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) - } - - if filter.Query != nil { - q := "%" + *filter.Query + "%" - query = query.Where("letters_incoming.subject ILIKE ? OR letters_incoming.reference_number ILIKE ?", q, q) - } - - 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 - 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.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 -} - -func (r *LetterIncomingRepository) ListAll(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") - - // Apply filters (same as ListAll) - if len(filter.PriorityIDs) > 0 { - query = query.Where("letters_incoming.priority_id IN ?", filter.PriorityIDs) - } - - if filter.Status != nil { - query = query.Where("letters_incoming.status = ?", *filter.Status) - } - - if filter.Query != nil { - q := "%" + *filter.Query + "%" - query = query.Where("letters_incoming.subject ILIKE ? OR letters_incoming.reference_number ILIKE ? OR letters_incoming.letter_number ILIKE ?", q, q, q) - } - - // Get total count - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - - // Get paginated data with preloaded relations - var list []entities.LetterIncoming - if err := query. - Limit(limit). - Offset(offset). - Find(&list).Error; err != nil { - return nil, 0, err - } - - return list, total, nil -} - -func (r *LetterIncomingRepository) Search(ctx context.Context, filters map[string]interface{}, limit, offset int, sortBy, sortOrder string) ([]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 - - // Apply search filters - if q, ok := filters["query"]; ok && q != "" { - searchTerm := "%" + q.(string) + "%" - query = query.Where("subject ILIKE ? OR reference_number ILIKE ? OR letter_number ILIKE ? OR description ILIKE ? OR sender_name ILIKE ?", searchTerm, searchTerm, searchTerm, searchTerm, searchTerm) - } - - if letterNumber, ok := filters["letter_number"]; ok && letterNumber != "" { - query = query.Where("letter_number ILIKE ?", "%"+letterNumber.(string)+"%") - } - - if subject, ok := filters["subject"]; ok && subject != "" { - query = query.Where("subject ILIKE ?", "%"+subject.(string)+"%") - } - - if status, ok := filters["status"]; ok && status != "" { - query = query.Where("status = ?", status) - } - - if priorityID, ok := filters["priority_id"]; ok { - query = query.Where("priority_id = ?", priorityID) - } - - if institutionID, ok := filters["sender_institution_id"]; ok { - query = query.Where("sender_institution_id = ?", institutionID) - } - - if createdBy, ok := filters["created_by"]; ok { - query = query.Where("created_by = ?", createdBy) - } - - if dateFrom, ok := filters["date_from"]; ok { - query = query.Where("received_date >= ?", dateFrom) - } - - if dateTo, ok := filters["date_to"]; ok { - query = query.Where("received_date <= ?", dateTo) - } - - // Apply user context filters if present - if userContext, ok := filters["user_context"]; ok { - if ctx, ok := userContext.(map[string]interface{}); ok { - if userID, ok := ctx["user_id"]; ok { - // User can see letters where they are recipients - query = query.Joins("JOIN letter_incoming_recipients ON letter_incoming_recipients.letter_id = letters_incoming.id"). - Where("letter_incoming_recipients.recipient_user_id = ?", userID) - joinedRecipients = true - needsGroupBy = true - } - if departmentID, ok := ctx["department_id"]; ok { - // Also include letters for user's department - 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_department_id = ?", departmentID) - } - } - } - - // Count total results - var total int64 - if needsGroupBy { - // For grouped queries, count distinct letter IDs - if err := db.WithContext(ctx).Model(&entities.LetterIncoming{}). - Joins("JOIN letter_incoming_recipients ON letter_incoming_recipients.letter_id = letters_incoming.id"). - Where("letters_incoming.deleted_at IS NULL"). - Distinct("letters_incoming.id"). - Count(&total).Error; err != nil { - return nil, 0, err - } - } else { - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - } - - // Apply sorting - if sortBy == "" { - sortBy = "created_at" - } - if sortOrder == "" { - sortOrder = "desc" - } - - validSortFields := map[string]bool{ - "letter_number": true, - "subject": true, - "received_date": true, - "status": true, - "created_at": true, - "updated_at": true, - } - - if !validSortFields[sortBy] { - sortBy = "created_at" - } - - if sortOrder != "asc" && sortOrder != "desc" { - sortOrder = "desc" - } - - orderBy := "letters_incoming." + sortBy + " " + sortOrder - - // Apply grouping if necessary - if needsGroupBy { - query = query.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") - } - - // Execute query - var letters []entities.LetterIncoming - if err := query. - Order(orderBy). - Limit(limit). - Offset(offset). - Find(&letters).Error; err != nil { - return nil, 0, err - } - - return letters, total, nil -} - -type LetterIncomingAttachmentRepository struct{ db *gorm.DB } - -func NewLetterIncomingAttachmentRepository(db *gorm.DB) *LetterIncomingAttachmentRepository { - return &LetterIncomingAttachmentRepository{db: db} -} - -func (r *LetterIncomingAttachmentRepository) CreateBulk(ctx context.Context, list []entities.LetterIncomingAttachment) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(&list).Error -} -func (r *LetterIncomingAttachmentRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingAttachment, error) { - db := DBFromContext(ctx, r.db) - var list []entities.LetterIncomingAttachment - if err := db.WithContext(ctx).Where("letter_id = ?", letterID).Order("uploaded_at ASC").Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -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 { - return &LetterIncomingActivityLogRepository{db: db} -} - -func (r *LetterIncomingActivityLogRepository) Create(ctx context.Context, e *entities.LetterIncomingActivityLog) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} - -func (r *LetterIncomingActivityLogRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingActivityLog, error) { - db := DBFromContext(ctx, r.db) - var list []entities.LetterIncomingActivityLog - if err := db.WithContext(ctx).Where("letter_id = ?", letterID).Order("occurred_at ASC").Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -type LetterIncomingDispositionRepository struct{ db *gorm.DB } - -func NewLetterIncomingDispositionRepository(db *gorm.DB) *LetterIncomingDispositionRepository { - return &LetterIncomingDispositionRepository{db: db} -} -func (r *LetterIncomingDispositionRepository) Create(ctx context.Context, e *entities.LetterIncomingDisposition) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} - -func (r *LetterIncomingDispositionRepository) 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 - if err := db.WithContext(ctx). - Where("letter_id = ?", letterID). - Preload("Department"). - Preload("Departments.Department"). - Preload("ActionSelections.Action"). - Preload("DispositionNotes.User"). - Order("created_at ASC"). - Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -func (r *LetterIncomingDispositionRepository) ListByLetterIDs(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterIncomingDisposition, error) { - if len(letterIDs) == 0 { - return make(map[uuid.UUID][]entities.LetterIncomingDisposition), nil - } - - db := DBFromContext(ctx, r.db) - var dispositions []entities.LetterIncomingDisposition - if err := db.WithContext(ctx).Where("letter_id IN ?", letterIDs). - Preload("Department"). - Preload("Departments.Department"). - Preload("ActionSelections.Action"). - Preload("DispositionNotes.User"). - Order("created_at ASC"). - Find(&dispositions).Error; err != nil { - return nil, err - } - - // Group by letter ID - result := make(map[uuid.UUID][]entities.LetterIncomingDisposition) - for i := range dispositions { // Gunakan index, bukan value - letterID := dispositions[i].LetterID - result[letterID] = append(result[letterID], dispositions[i]) - } - - return result, 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 - if err := db.WithContext(ctx).Where("letter_incoming_disposition_id = ?", dispositionID).Order("created_at ASC").Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -func (r *LetterIncomingDispositionDepartmentRepository) ListByDispositions(ctx context.Context, dispositionIDs []uuid.UUID) ([]entities.LetterIncomingDispositionDepartment, error) { - db := DBFromContext(ctx, r.db) - var list []entities.LetterIncomingDispositionDepartment - if len(dispositionIDs) == 0 { - return list, nil - } - if err := db.WithContext(ctx).Where("letter_incoming_disposition_id IN ?", dispositionIDs).Order("letter_incoming_disposition_id, created_at ASC").Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -type DispositionNoteRepository struct{ db *gorm.DB } - -func NewDispositionNoteRepository(db *gorm.DB) *DispositionNoteRepository { - return &DispositionNoteRepository{db: db} -} -func (r *DispositionNoteRepository) Create(ctx context.Context, e *entities.DispositionNote) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} - -func (r *DispositionNoteRepository) ListByDisposition(ctx context.Context, dispositionID uuid.UUID) ([]entities.DispositionNote, error) { - db := DBFromContext(ctx, r.db) - var list []entities.DispositionNote - if err := db.WithContext(ctx).Where("disposition_id = ?", dispositionID).Order("created_at ASC").Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -func (r *DispositionNoteRepository) ListByDispositions(ctx context.Context, dispositionIDs []uuid.UUID) ([]entities.DispositionNote, error) { - db := DBFromContext(ctx, r.db) - var list []entities.DispositionNote - if len(dispositionIDs) == 0 { - return list, nil - } - if err := db.WithContext(ctx).Where("disposition_id IN ?", dispositionIDs).Order("disposition_id, created_at ASC").Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -type LetterDispositionActionSelectionRepository struct{ db *gorm.DB } - -func NewLetterDispositionActionSelectionRepository(db *gorm.DB) *LetterDispositionActionSelectionRepository { - return &LetterDispositionActionSelectionRepository{db: db} -} -func (r *LetterDispositionActionSelectionRepository) CreateBulk(ctx context.Context, list []entities.LetterDispositionActionSelection) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(&list).Error -} -func (r *LetterDispositionActionSelectionRepository) ListByDisposition(ctx context.Context, dispositionID uuid.UUID) ([]entities.LetterDispositionActionSelection, error) { - db := DBFromContext(ctx, r.db) - var list []entities.LetterDispositionActionSelection - if err := db.WithContext(ctx).Where("disposition_id = ?", dispositionID).Order("created_at ASC").Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -func (r *LetterDispositionActionSelectionRepository) ListByDispositions(ctx context.Context, dispositionIDs []uuid.UUID) ([]entities.LetterDispositionActionSelection, error) { - db := DBFromContext(ctx, r.db) - var list []entities.LetterDispositionActionSelection - if len(dispositionIDs) == 0 { - return list, nil - } - if err := db.WithContext(ctx).Where("disposition_id IN ?", dispositionIDs).Order("disposition_id, created_at ASC").Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -type LetterDiscussionRepository struct{ db *gorm.DB } - -func NewLetterDiscussionRepository(db *gorm.DB) *LetterDiscussionRepository { - return &LetterDiscussionRepository{db: db} -} -func (r *LetterDiscussionRepository) Create(ctx context.Context, e *entities.LetterDiscussion) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(e).Error -} -func (r *LetterDiscussionRepository) Get(ctx context.Context, id uuid.UUID) (*entities.LetterDiscussion, error) { - db := DBFromContext(ctx, r.db) - var e entities.LetterDiscussion - if err := db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil { - return nil, err - } - return &e, nil -} -func (r *LetterDiscussionRepository) Update(ctx context.Context, e *entities.LetterDiscussion) error { - db := DBFromContext(ctx, r.db) - // ensure edited_at is set when updating - if e.EditedAt == nil { - now := time.Now() - e.EditedAt = &now - } - return db.WithContext(ctx).Model(&entities.LetterDiscussion{}). - Where("id = ?", e.ID). - Updates(map[string]interface{}{"message": e.Message, "mentions": e.Mentions, "edited_at": e.EditedAt}).Error -} - -func (r *LetterDiscussionRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterDiscussion, error) { - db := DBFromContext(ctx, r.db) - var list []entities.LetterDiscussion - if err := db.WithContext(ctx). - Where("letter_id = ?", letterID). - Preload("User.Profile"). - Order("created_at ASC"). - Find(&list).Error; err != nil { - return nil, err - } - return list, nil -} - -func (r *LetterDiscussionRepository) GetUsersByIDs(ctx context.Context, userIDs []uuid.UUID) ([]entities.User, error) { - if len(userIDs) == 0 { - return []entities.User{}, nil - } - - db := DBFromContext(ctx, r.db) - var users []entities.User - if err := db.WithContext(ctx). - Where("id IN ?", userIDs). - Preload("Profile"). - Find(&users).Error; err != nil { - return nil, err - } - return users, nil -} - -// recipients - -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 - if err := db.WithContext(ctx). - Model(&entities.LetterIncomingRecipient{}). - Where("recipient_department_id = ?", departmentID). - Distinct("letter_id"). - Pluck("letter_id", &letterIDs).Error; err != nil { - return nil, err - } - 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 - - sql := ` - WITH valid_recipients AS ( - SELECT lir.id, lir.letter_id, lir.read_at, lir.created_at, - ROW_NUMBER() OVER (PARTITION BY lir.letter_id ORDER BY lir.created_at DESC) as rn - FROM letter_incoming_recipients lir - INNER JOIN letters_incoming l ON l.id = lir.letter_id AND l.deleted_at IS NULL - WHERE lir.recipient_user_id = ? - ) - SELECT COUNT(*) - FROM valid_recipients - WHERE rn = 1 AND read_at IS NULL - ` - - if err := db.WithContext(ctx).Raw(sql, userID).Scan(&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(`id IN ( - SELECT id FROM ( - SELECT id, ROW_NUMBER() OVER (PARTITION BY letter_id ORDER BY created_at DESC) as rn - FROM letter_incoming_recipients - WHERE letter_id IN ? AND recipient_user_id = ? - ) t WHERE rn = 1 - )`, 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 - if err := db.WithContext(ctx). - Model(&entities.LetterIncomingRecipient{}). - Where("letter_id = ? AND recipient_department_id = ?", letterID, departmentID). - Count(&count).Error; err != nil { - return false, err - } - return count > 0, nil -} diff --git a/internal/repository/master_repository.go b/internal/repository/master_repository.go deleted file mode 100644 index 6fad42c..0000000 --- a/internal/repository/master_repository.go +++ /dev/null @@ -1,389 +0,0 @@ -package repository - -import ( - "context" - - "eslogad-be/internal/entities" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type LabelRepository struct{ db *gorm.DB } - -func NewLabelRepository(db *gorm.DB) *LabelRepository { return &LabelRepository{db: db} } -func (r *LabelRepository) Create(ctx context.Context, e *entities.Label) error { - return r.db.WithContext(ctx).Create(e).Error -} -func (r *LabelRepository) Update(ctx context.Context, e *entities.Label) error { - return r.db.WithContext(ctx).Model(&entities.Label{}).Where("id = ?", e.ID).Updates(e).Error -} -func (r *LabelRepository) Delete(ctx context.Context, id uuid.UUID) error { - return r.db.WithContext(ctx).Delete(&entities.Label{}, "id = ?", id).Error -} -func (r *LabelRepository) List(ctx context.Context) ([]entities.Label, error) { - var list []entities.Label - err := r.db.WithContext(ctx).Order("name ASC").Find(&list).Error - return list, err -} -func (r *LabelRepository) Get(ctx context.Context, id uuid.UUID) (*entities.Label, error) { - var e entities.Label - if err := r.db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil { - return nil, err - } - return &e, nil -} - -type PriorityRepository struct{ db *gorm.DB } - -func NewPriorityRepository(db *gorm.DB) *PriorityRepository { return &PriorityRepository{db: db} } -func (r *PriorityRepository) Create(ctx context.Context, e *entities.Priority) error { - return r.db.WithContext(ctx).Create(e).Error -} -func (r *PriorityRepository) Update(ctx context.Context, e *entities.Priority) error { - return r.db.WithContext(ctx).Model(&entities.Priority{}).Where("id = ?", e.ID).Updates(e).Error -} -func (r *PriorityRepository) Delete(ctx context.Context, id uuid.UUID) error { - return r.db.WithContext(ctx).Delete(&entities.Priority{}, "id = ?", id).Error -} -func (r *PriorityRepository) List(ctx context.Context) ([]entities.Priority, error) { - var list []entities.Priority - err := r.db.WithContext(ctx).Order("level ASC").Find(&list).Error - return list, err -} -func (r *PriorityRepository) Get(ctx context.Context, id uuid.UUID) (*entities.Priority, error) { - var e entities.Priority - if err := r.db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil { - return nil, err - } - return &e, nil -} - -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 { - return &InstitutionRepository{db: db} -} -func (r *InstitutionRepository) Create(ctx context.Context, e *entities.Institution) error { - return r.db.WithContext(ctx).Create(e).Error -} -func (r *InstitutionRepository) Update(ctx context.Context, e *entities.Institution) error { - return r.db.WithContext(ctx).Model(&entities.Institution{}).Where("id = ?", e.ID).Updates(e).Error -} -func (r *InstitutionRepository) Delete(ctx context.Context, id uuid.UUID) error { - return r.db.WithContext(ctx).Delete(&entities.Institution{}, "id = ?", id).Error -} -func (r *InstitutionRepository) List(ctx context.Context) ([]entities.Institution, error) { - var list []entities.Institution - err := r.db.WithContext(ctx).Order("name ASC").Find(&list).Error - return list, err -} - -func (r *InstitutionRepository) ListWithSearch(ctx context.Context, search *string) ([]entities.Institution, error) { - var list []entities.Institution - q := r.db.WithContext(ctx).Model(&entities.Institution{}) - - if search != nil && *search != "" { - like := "%" + *search + "%" - q = q.Where("name ILIKE ? OR type ILIKE ? OR address ILIKE ? OR contact_person ILIKE ?", like, like, like, like) - } - - err := q.Order("name ASC").Find(&list).Error - return list, err -} - -func (r *InstitutionRepository) Get(ctx context.Context, id uuid.UUID) (*entities.Institution, error) { - var e entities.Institution - if err := r.db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil { - return nil, err - } - 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 { - return &DispositionActionRepository{db: db} -} -func (r *DispositionActionRepository) Create(ctx context.Context, e *entities.DispositionAction) error { - return r.db.WithContext(ctx).Create(e).Error -} -func (r *DispositionActionRepository) Update(ctx context.Context, e *entities.DispositionAction) error { - return r.db.WithContext(ctx).Model(&entities.DispositionAction{}).Where("id = ?", e.ID).Updates(e).Error -} -func (r *DispositionActionRepository) Delete(ctx context.Context, id uuid.UUID) error { - return r.db.WithContext(ctx).Delete(&entities.DispositionAction{}, "id = ?", id).Error -} -func (r *DispositionActionRepository) List(ctx context.Context) ([]entities.DispositionAction, error) { - var list []entities.DispositionAction - err := r.db.WithContext(ctx).Order("sort_order NULLS LAST, label ASC").Find(&list).Error - return list, err -} -func (r *DispositionActionRepository) Get(ctx context.Context, id uuid.UUID) (*entities.DispositionAction, error) { - var e entities.DispositionAction - if err := r.db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil { - return nil, err - } - return &e, nil -} - -func (r *DispositionActionRepository) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]entities.DispositionAction, error) { - var actions []entities.DispositionAction - if len(ids) == 0 { - return actions, nil - } - if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&actions).Error; err != nil { - return nil, err - } - return actions, nil -} - -type DepartmentRepository struct{ db *gorm.DB } - -func NewDepartmentRepository(db *gorm.DB) *DepartmentRepository { return &DepartmentRepository{db: db} } - -func (r *DepartmentRepository) GetByCode(ctx context.Context, code string) (*entities.Department, error) { - db := DBFromContext(ctx, r.db) - var dep entities.Department - if err := db.WithContext(ctx).Where("code = ?", code).First(&dep).Error; err != nil { - return nil, err - } - return &dep, nil -} - -func (r *DepartmentRepository) Get(ctx context.Context, id uuid.UUID) (*entities.Department, error) { - db := DBFromContext(ctx, r.db) - var dep entities.Department - if err := db.WithContext(ctx).First(&dep, "id = ?", id).Error; err != nil { - return nil, err - } - return &dep, nil -} - -func (r *DepartmentRepository) List(ctx context.Context, search string, limit, offset int) ([]entities.Department, int64, error) { - db := DBFromContext(ctx, r.db) - - query := db.WithContext(ctx).Model(&entities.Department{}) - - // Add search filter if provided - if search != "" { - query = query.Where("name ILIKE ?", "%"+search+"%") - } - - // Get total count - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - - // Get paginated results - var list []entities.Department - if err := query. - Order("name ASC"). - Limit(limit). - Offset(offset). - Find(&list).Error; err != nil { - return nil, 0, err - } - - return list, total, nil -} - -func (r *DepartmentRepository) ListWithParentFilter(ctx context.Context, search string, limit, offset int, parentPath string, excludedPaths []string) ([]entities.Department, int64, error) { - db := DBFromContext(ctx, r.db) - - query := db.WithContext(ctx).Model(&entities.Department{}) - - // Filter by parent path if provided - include the parent itself and all descendants - if parentPath != "" { - query = query.Where("path = ? OR path <@ ?", parentPath, parentPath) - } - - // Exclude specific paths - for _, excludedPath := range excludedPaths { - query = query.Where("NOT (path ~ ?)", excludedPath) - } - - // Add search filter if provided - if search != "" { - query = query.Where("name ILIKE ? OR code ILIKE ?", "%"+search+"%", "%"+search+"%") - } - - // Get total count - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - - // Get paginated results - var list []entities.Department - if err := query. - Order("name ASC"). - Limit(limit). - Offset(offset). - Find(&list).Error; err != nil { - return nil, 0, err - } - - 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 -} - -func (r *DepartmentRepository) Create(ctx context.Context, department *entities.Department) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Create(department).Error -} - -func (r *DepartmentRepository) Update(ctx context.Context, department *entities.Department) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Save(department).Error -} - -func (r *DepartmentRepository) Delete(ctx context.Context, id uuid.UUID) error { - db := DBFromContext(ctx, r.db) - return db.WithContext(ctx).Delete(&entities.Department{}, "id = ?", id).Error -} - -func (r *DepartmentRepository) GetByPath(ctx context.Context, path string) (*entities.Department, error) { - db := DBFromContext(ctx, r.db) - var department entities.Department - if err := db.WithContext(ctx).Where("path = ?", path).First(&department).Error; err != nil { - return nil, err - } - return &department, nil -} - -func (r *DepartmentRepository) GetAll(ctx context.Context) ([]entities.Department, error) { - db := DBFromContext(ctx, r.db) - var departments []entities.Department - if err := db.WithContext(ctx).Order("path ASC").Find(&departments).Error; err != nil { - return nil, err - } - return departments, nil -} - -func (r *DepartmentRepository) GetAllWithParentFilter(ctx context.Context, parentPath string, excludedPaths []string) ([]entities.Department, error) { - db := DBFromContext(ctx, r.db) - var departments []entities.Department - - query := db.WithContext(ctx) - - // Filter by parent path if provided - include the parent itself and all descendants - if parentPath != "" { - query = query.Where("path = ? OR path <@ ?", parentPath, parentPath) - } - - // Exclude specific paths - for _, excludedPath := range excludedPaths { - query = query.Where("NOT (path ~ ?)", excludedPath) - } - - if err := query.Order("path ASC").Find(&departments).Error; err != nil { - return nil, err - } - return departments, nil -} - -func (r *DepartmentRepository) GetByPathPrefix(ctx context.Context, pathPrefix string) ([]entities.Department, error) { - db := DBFromContext(ctx, r.db) - var departments []entities.Department - // Using ltree operators for hierarchical queries - query := db.WithContext(ctx).Order("path ASC") - if pathPrefix != "" { - // Get all descendants of a path - query = query.Where("path <@ ?", pathPrefix) - } - if err := query.Find(&departments).Error; err != nil { - return nil, err - } - return departments, nil -} - -func (r *DepartmentRepository) GetChildren(ctx context.Context, parentPath string) ([]entities.Department, error) { - db := DBFromContext(ctx, r.db) - var departments []entities.Department - // Get direct children and all descendants - if err := db.WithContext(ctx). - Where("path <@ ? AND path != ?", parentPath, parentPath). - Order("path ASC"). - Find(&departments).Error; err != nil { - return nil, err - } - return departments, nil -} - -func (r *DepartmentRepository) GetAllDescendants(ctx context.Context, parentID uuid.UUID) ([]entities.Department, error) { - db := DBFromContext(ctx, r.db) - - // First get the parent department to get its path - var parent entities.Department - if err := db.WithContext(ctx).First(&parent, "id = ?", parentID).Error; err != nil { - return nil, err - } - - var departments []entities.Department - // Get all descendants using ltree - if err := db.WithContext(ctx). - Where("path <@ ? AND path != ?", parent.Path, parent.Path). - Order("path ASC"). - Find(&departments).Error; err != nil { - return nil, err - } - return departments, nil -} - -func (r *DepartmentRepository) UpdateChildrenPaths(ctx context.Context, oldPath, newPath string) error { - db := DBFromContext(ctx, r.db) - // Use raw SQL for ltree path update - // This will update all children paths by replacing the old prefix with the new one - query := ` - UPDATE departments - SET path = ? || subpath(path, nlevel(?)) - WHERE path <@ ? AND path != ? - ` - return db.WithContext(ctx).Exec(query, newPath, oldPath, oldPath, oldPath).Error -} diff --git a/internal/repository/rbac_repository.go b/internal/repository/rbac_repository.go deleted file mode 100644 index 8a17814..0000000 --- a/internal/repository/rbac_repository.go +++ /dev/null @@ -1,175 +0,0 @@ -package repository - -import ( - "context" - - "eslogad-be/internal/entities" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type RBACRepository struct { - db *gorm.DB -} - -func NewRBACRepository(db *gorm.DB) *RBACRepository { return &RBACRepository{db: db} } - -// Permissions -func (r *RBACRepository) CreatePermission(ctx context.Context, p *entities.Permission) error { - return r.db.WithContext(ctx).Create(p).Error -} -func (r *RBACRepository) UpdatePermission(ctx context.Context, p *entities.Permission) error { - return r.db.WithContext(ctx).Model(&entities.Permission{}).Where("id = ?", p.ID).Updates(p).Error -} -func (r *RBACRepository) DeletePermission(ctx context.Context, id uuid.UUID) error { - return r.db.WithContext(ctx).Delete(&entities.Permission{}, "id = ?", id).Error -} -func (r *RBACRepository) ListPermissions(ctx context.Context) ([]entities.Permission, error) { - var perms []entities.Permission - if err := r.db.WithContext(ctx).Preload("Module").Order("code ASC").Find(&perms).Error; err != nil { - return nil, err - } - return perms, nil -} -func (r *RBACRepository) GetPermissionByCode(ctx context.Context, code string) (*entities.Permission, error) { - var p entities.Permission - if err := r.db.WithContext(ctx).First(&p, "code = ?", code).Error; err != nil { - return nil, err - } - return &p, nil -} - -// Roles -func (r *RBACRepository) CreateRole(ctx context.Context, role *entities.Role) error { - return r.db.WithContext(ctx).Create(role).Error -} -func (r *RBACRepository) UpdateRole(ctx context.Context, role *entities.Role) error { - return r.db.WithContext(ctx).Model(&entities.Role{}).Where("id = ?", role.ID).Updates(role).Error -} -func (r *RBACRepository) DeleteRole(ctx context.Context, id uuid.UUID) error { - return r.db.WithContext(ctx).Delete(&entities.Role{}, "id = ?", id).Error -} -func (r *RBACRepository) ListRoles(ctx context.Context) ([]entities.Role, error) { - var roles []entities.Role - if err := r.db.WithContext(ctx).Order("name ASC").Find(&roles).Error; err != nil { - return nil, err - } - return roles, nil -} -func (r *RBACRepository) GetRoleByCode(ctx context.Context, code string) (*entities.Role, error) { - var role entities.Role - if err := r.db.WithContext(ctx).First(&role, "code = ?", code).Error; err != nil { - return nil, err - } - return &role, nil -} - -func (r *RBACRepository) SetRolePermissionsByCodes(ctx context.Context, roleID uuid.UUID, permCodes []string) error { - if err := r.db.WithContext(ctx).Where("role_id = ?", roleID).Delete(&entities.RolePermission{}).Error; err != nil { - return err - } - if len(permCodes) == 0 { - return nil - } - var perms []entities.Permission - if err := r.db.WithContext(ctx).Where("code IN ?", permCodes).Find(&perms).Error; err != nil { - return err - } - pairs := make([]entities.RolePermission, 0, len(perms)) - for _, p := range perms { - pairs = append(pairs, entities.RolePermission{RoleID: roleID, PermissionID: p.ID}) - } - return r.db.WithContext(ctx).Create(&pairs).Error -} - -func (r *RBACRepository) GetPermissionsByRoleID(ctx context.Context, roleID uuid.UUID) ([]entities.Permission, error) { - var perms []entities.Permission - if err := r.db.WithContext(ctx). - Preload("Module"). - Table("permissions p"). - Select("p.*"). - Joins("JOIN role_permissions rp ON rp.permission_id = p.id"). - Where("rp.role_id = ?", roleID). - Find(&perms).Error; err != nil { - return nil, err - } - return perms, nil -} - -// Modules -func (r *RBACRepository) CreateModule(ctx context.Context, m *entities.Module) error { - return r.db.WithContext(ctx).Create(m).Error -} - -func (r *RBACRepository) UpdateModule(ctx context.Context, m *entities.Module) error { - return r.db.WithContext(ctx).Model(&entities.Module{}).Where("id = ?", m.ID).Updates(m).Error -} - -func (r *RBACRepository) DeleteModule(ctx context.Context, id uuid.UUID) error { - return r.db.WithContext(ctx).Delete(&entities.Module{}, "id = ?", id).Error -} - -func (r *RBACRepository) ListModules(ctx context.Context) ([]entities.Module, error) { - var modules []entities.Module - if err := r.db.WithContext(ctx).Order("name ASC").Find(&modules).Error; err != nil { - return nil, err - } - return modules, nil -} - -func (r *RBACRepository) GetModuleByCode(ctx context.Context, code string) (*entities.Module, error) { - var m entities.Module - if err := r.db.WithContext(ctx).First(&m, "code = ?", code).Error; err != nil { - return nil, err - } - return &m, nil -} - -func (r *RBACRepository) GetModuleByID(ctx context.Context, id uuid.UUID) (*entities.Module, error) { - var m entities.Module - if err := r.db.WithContext(ctx).First(&m, "id = ?", id).Error; err != nil { - return nil, err - } - return &m, nil -} - -func (r *RBACRepository) GetPermissionsGroupedByModule(ctx context.Context) ([]entities.Module, error) { - var modules []entities.Module - if err := r.db.WithContext(ctx). - Preload("Permissions", func(db *gorm.DB) *gorm.DB { - return db.Order("action ASC") - }). - Find(&modules).Error; err != nil { - return nil, err - } - return modules, nil -} - -func (r *RBACRepository) GetRoleByID(ctx context.Context, id uuid.UUID) (*entities.Role, error) { - var role entities.Role - if err := r.db.WithContext(ctx).First(&role, "id = ?", id).Error; err != nil { - return nil, err - } - return &role, nil -} - -func (r *RBACRepository) SetRolePermissionsByIDs(ctx context.Context, roleID uuid.UUID, permissionIDs []uuid.UUID) error { - return r.db.Transaction(func(tx *gorm.DB) error { - // Delete existing permissions - if err := tx.WithContext(ctx).Where("role_id = ?", roleID).Delete(&entities.RolePermission{}).Error; err != nil { - return err - } - - // Add new permissions - if len(permissionIDs) == 0 { - return nil - } - - pairs := make([]entities.RolePermission, 0, len(permissionIDs)) - for _, permID := range permissionIDs { - pairs = append(pairs, entities.RolePermission{RoleID: roleID, PermissionID: permID}) - } - return tx.WithContext(ctx).Create(&pairs).Error - }) -} diff --git a/internal/repository/repository_attachment_repository.go b/internal/repository/repository_attachment_repository.go deleted file mode 100644 index 6ece0bf..0000000 --- a/internal/repository/repository_attachment_repository.go +++ /dev/null @@ -1,74 +0,0 @@ -package repository - -import ( - "context" - "eslogad-be/internal/entities" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type RepositoryAttachmentRepositoryImpl struct { - b *gorm.DB -} - -func NewRepositoryAttachmentRepositoryImpl(db *gorm.DB) *RepositoryAttachmentRepositoryImpl { - return &RepositoryAttachmentRepositoryImpl{ - b: db, - } -} - -func (r *RepositoryAttachmentRepositoryImpl) Create(ctx context.Context, user *entities.RepositoryAttachment) error { - return r.b.WithContext(ctx).Create(user).Error -} - -func (r *RepositoryAttachmentRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.RepositoryAttachment, error) { - var attachment entities.RepositoryAttachment - err := r.b.WithContext(ctx). - First(&attachment, "id = ?", id).Error - if err != nil { - return nil, err - } - return &attachment, nil -} - -func (r *RepositoryAttachmentRepositoryImpl) Update(ctx context.Context, user *entities.RepositoryAttachment) error { - return r.b.WithContext(ctx).Save(user).Error -} - -func (r *RepositoryAttachmentRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error { - return r.b.WithContext(ctx).Delete(&entities.RepositoryAttachment{}, "id = ?", id).Error -} - -func (r *RepositoryAttachmentRepositoryImpl) List(ctx context.Context, search *string, limit, offset int) ([]*entities.RepositoryAttachment, int64, error) { - var attachments []*entities.RepositoryAttachment - var total int64 - - baseQuery := r.b.WithContext(ctx).Model(&entities.RepositoryAttachment{}) - - if search != nil && *search != "" { - like := "%" + *search + "%" - baseQuery = baseQuery.Where("name ILIKE ? OR email ILIKE ?", like, like) - } - - countQuery := baseQuery - if err := countQuery.Count(&total).Error; err != nil { - return nil, 0, err - } - - dataQuery := r.b.WithContext(ctx).Model(&entities.RepositoryAttachment{}) - - if search != nil && *search != "" { - like := "%" + *search + "%" - dataQuery = dataQuery.Where("name ILIKE ? OR category ILIKE ?", like, like) - } - - if err := dataQuery. - Limit(limit). - Offset(offset). - Find(&attachments).Error; err != nil { - return nil, 0, err - } - - return attachments, total, nil -} diff --git a/internal/repository/title_repository.go b/internal/repository/title_repository.go deleted file mode 100644 index 23909a2..0000000 --- a/internal/repository/title_repository.go +++ /dev/null @@ -1,25 +0,0 @@ -package repository - -import ( - "context" - - "eslogad-be/internal/entities" - - "gorm.io/gorm" -) - -type TitleRepository struct { - db *gorm.DB -} - -func NewTitleRepository(db *gorm.DB) *TitleRepository { - return &TitleRepository{db: db} -} - -func (r *TitleRepository) ListAll(ctx context.Context) ([]entities.Title, error) { - var titles []entities.Title - if err := r.db.WithContext(ctx).Order("name ASC").Find(&titles).Error; err != nil { - return nil, err - } - return titles, nil -} diff --git a/internal/repository/user_department_repository.go b/internal/repository/user_department_repository.go deleted file mode 100644 index 7fafe4a..0000000 --- a/internal/repository/user_department_repository.go +++ /dev/null @@ -1,33 +0,0 @@ -package repository - -import ( - "context" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type UserDepartmentRepository struct{ db *gorm.DB } - -func NewUserDepartmentRepository(db *gorm.DB) *UserDepartmentRepository { - return &UserDepartmentRepository{db: db} -} - -type UserDepartmentRow struct { - UserID uuid.UUID `gorm:"column:user_id"` - DepartmentID uuid.UUID `gorm:"column:department_id"` -} - -func (r *UserDepartmentRepository) ListActiveByDepartmentIDs(ctx context.Context, departmentIDs []uuid.UUID) ([]UserDepartmentRow, error) { - db := DBFromContext(ctx, r.db) - rows := make([]UserDepartmentRow, 0) - if len(departmentIDs) == 0 { - return rows, nil - } - err := db.WithContext(ctx). - Table("user_department"). - Select("user_id, department_id"). - Where("department_id IN ? AND removed_at IS NULL", departmentIDs). - Find(&rows).Error - return rows, err -} diff --git a/internal/repository/user_profile_repository.go b/internal/repository/user_profile_repository.go index fca1d60..c523969 100644 --- a/internal/repository/user_profile_repository.go +++ b/internal/repository/user_profile_repository.go @@ -6,7 +6,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" - "eslogad-be/internal/entities" + "go-backend-template/internal/entities" "github.com/google/uuid" ) diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index c3ceef8..5317380 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -3,7 +3,7 @@ package repository import ( "context" - "eslogad-be/internal/entities" + "go-backend-template/internal/entities" "github.com/google/uuid" "gorm.io/gorm" @@ -27,7 +27,6 @@ func (r *UserRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entiti var user entities.User err := r.b.WithContext(ctx). Preload("Profile"). - Preload("Departments"). First(&user, "id = ?", id).Error if err != nil { return nil, err @@ -51,7 +50,6 @@ func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*ent var user entities.User err := r.b.WithContext(ctx). Preload("Profile"). - Preload("Departments"). Where("email = ?", email).First(&user).Error if err != nil { return nil, err @@ -59,18 +57,28 @@ func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*ent return &user, nil } +func (r *UserRepositoryImpl) GetByUsername(ctx context.Context, username string) (*entities.User, error) { + var user entities.User + err := r.b.WithContext(ctx). + Preload("Profile"). + Where("username = ?", username).First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + func (r *UserRepositoryImpl) GetByRole(ctx context.Context, role entities.UserRole) ([]*entities.User, error) { var users []*entities.User - err := r.b.WithContext(ctx).Preload("Profile").Preload("Departments").Where("role = ?", role).Find(&users).Error + err := r.b.WithContext(ctx).Preload("Profile").Where("role = ?", role).Find(&users).Error return users, err } -func (r *UserRepositoryImpl) GetActiveUsers(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error) { +func (r *UserRepositoryImpl) GetActiveUsers(ctx context.Context) ([]*entities.User, error) { var users []*entities.User err := r.b.WithContext(ctx). - Where(" is_active = ?", organizationID, true). + Where("is_active = ?", true). Preload("Profile"). - Preload("Departments"). Find(&users).Error return users, err } @@ -109,7 +117,7 @@ func (r *UserRepositoryImpl) List(ctx context.Context, filters map[string]interf return nil, 0, err } - err := query.Limit(limit).Offset(offset).Preload("Profile").Preload("Departments").Find(&users).Error + err := query.Limit(limit).Offset(offset).Preload("Profile").Find(&users).Error return users, total, err } @@ -125,155 +133,50 @@ func (r *UserRepositoryImpl) Count(ctx context.Context, filters map[string]inter return count, err } -// RBAC helpers -func (r *UserRepositoryImpl) GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) { - var roles []entities.Role - err := r.b.WithContext(ctx). - Table("roles as r"). - Select("r.*"). - Joins("JOIN user_role ur ON ur.role_id = r.id AND ur.removed_at IS NULL"). - Where("ur.user_id = ?", userID). - Find(&roles).Error - return roles, err -} - -func (r *UserRepositoryImpl) GetPermissionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Permission, error) { - var perms []entities.Permission - err := r.b.WithContext(ctx). - Table("permissions as p"). - Select("DISTINCT p.*"). - Joins("JOIN role_permissions rp ON rp.permission_id = p.id"). - Joins("JOIN user_role ur ON ur.role_id = rp.role_id AND ur.removed_at IS NULL"). - Where("ur.user_id = ?", userID). - Find(&perms).Error - return perms, err -} - -func (r *UserRepositoryImpl) GetDepartmentsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Department, error) { - var departments []entities.Department - err := r.b.WithContext(ctx). - Table("departments as d"). - Select("d.*"). - Joins("JOIN user_department ud ON ud.department_id = d.id AND ud.removed_at IS NULL"). - Where("ud.user_id = ?", userID). - Find(&departments).Error - return departments, err -} - -func (r *UserRepositoryImpl) GetRolesByUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]entities.Role, error) { - result := make(map[uuid.UUID][]entities.Role) - if len(userIDs) == 0 { - return result, nil - } - - type row struct { - UserID uuid.UUID - RoleID uuid.UUID - Name string - Code string - } - - var rows []row - err := r.b.WithContext(ctx). - Table("user_role as ur"). - Select("ur.user_id, r.id as role_id, r.name, r.code"). - Joins("JOIN roles r ON r.id = ur.role_id"). - Where("ur.removed_at IS NULL AND ur.user_id IN ?", userIDs). - Scan(&rows).Error - if err != nil { - return nil, err - } - for _, rw := range rows { - role := entities.Role{ID: rw.RoleID, Name: rw.Name, Code: rw.Code} - result[rw.UserID] = append(result[rw.UserID], role) - } - return result, nil -} - -func (r *UserRepositoryImpl) ListWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]*entities.User, int64, error) { +func (r *UserRepositoryImpl) GetAll(ctx context.Context, page, limit int) ([]*entities.User, int64, error) { var users []*entities.User var total int64 - // Build base query - use Model directly without Table for proper field mapping - baseQuery := r.b.WithContext(ctx).Model(&entities.User{}) - - if search != nil && *search != "" { - like := "%" + *search + "%" - baseQuery = baseQuery.Where("name ILIKE ? OR email ILIKE ?", like, like) - } + offset := (page - 1) * limit - if isActive != nil { - baseQuery = baseQuery.Where("is_active = ?", *isActive) - } + query := r.b.WithContext(ctx).Model(&entities.User{}) - // For counting with role filter, we need to use a subquery or join - countQuery := baseQuery - if roleCode != nil && *roleCode != "" { - countQuery = countQuery. - Joins("JOIN user_role ur ON ur.user_id = users.id AND ur.removed_at IS NULL"). - Joins("JOIN roles r ON r.id = ur.role_id"). - Where("r.code = ?", *roleCode) - } - - // Get total count - if err := countQuery.Count(&total).Error; err != nil { + if err := query.Count(&total).Error; err != nil { return nil, 0, err } - // Build query for fetching data - dataQuery := r.b.WithContext(ctx).Model(&entities.User{}) - + err := query.Limit(limit).Offset(offset).Preload("Profile").Find(&users).Error + return users, total, err +} + +func (r *UserRepositoryImpl) ListWithFilters(ctx context.Context, search *string, isActive *bool, limit, offset int) ([]*entities.User, int64, error) { + var users []*entities.User + var total int64 + + query := r.b.WithContext(ctx).Model(&entities.User{}) + if search != nil && *search != "" { like := "%" + *search + "%" - dataQuery = dataQuery.Where("name ILIKE ? OR email ILIKE ?", like, like) + query = query.Where("name ILIKE ? OR email ILIKE ? OR username ILIKE ?", like, like, like) } if isActive != nil { - dataQuery = dataQuery.Where("is_active = ?", *isActive) + query = query.Where("is_active = ?", *isActive) } - if roleCode != nil && *roleCode != "" { - dataQuery = dataQuery. - Joins("JOIN user_role ur ON ur.user_id = users.id AND ur.removed_at IS NULL"). - Joins("JOIN roles r ON r.id = ur.role_id"). - Where("r.code = ?", *roleCode) + // Get total count + if err := query.Count(&total).Error; err != nil { + return nil, 0, err } // Fetch users with preloads - if err := dataQuery. + if err := query. Limit(limit). Offset(offset). Preload("Profile"). - Preload("Departments"). Find(&users).Error; err != nil { return nil, 0, err } return users, total, nil } - -func (r *UserRepositoryImpl) GetByIDWithDepartments(ctx context.Context, id uuid.UUID) (*entities.User, error) { - var user entities.User - err := r.b.WithContext(ctx). - Preload("Profile"). - Preload("Departments"). - First(&user, "id = ?", id).Error - if err != nil { - return nil, err - } - return &user, nil -} - -func (r *UserRepositoryImpl) UpdateDepartments(ctx context.Context, userID uuid.UUID, departments []entities.Department) error { - // First, clear existing associations - if err := r.b.WithContext(ctx).Model(&entities.User{ID: userID}).Association("Departments").Clear(); err != nil { - return err - } - - // Then add new associations - if len(departments) > 0 { - return r.b.WithContext(ctx).Model(&entities.User{ID: userID}).Association("Departments").Append(&departments) - } - - return nil -} diff --git a/internal/router/auth_handler.go b/internal/router/auth_handler.go index 2324674..1ad894f 100644 --- a/internal/router/auth_handler.go +++ b/internal/router/auth_handler.go @@ -4,6 +4,4 @@ import "github.com/gin-gonic/gin" type AuthHandler interface { Login(c *gin.Context) - RefreshToken(c *gin.Context) - GetProfile(c *gin.Context) } diff --git a/internal/router/dukcapil_handler.go b/internal/router/dukcapil_handler.go new file mode 100644 index 0000000..b8429c4 --- /dev/null +++ b/internal/router/dukcapil_handler.go @@ -0,0 +1,7 @@ +package router + +import "github.com/gin-gonic/gin" + +type DukcapilHandler interface { + FaceMatch(c *gin.Context) +} diff --git a/internal/router/health_handler.go b/internal/router/health_handler.go index a4187b3..3252902 100644 --- a/internal/router/health_handler.go +++ b/internal/router/health_handler.go @@ -11,186 +11,4 @@ type UserHandler interface { CreateUser(c *gin.Context) UpdateUser(c *gin.Context) DeleteUser(c *gin.Context) - GetProfile(c *gin.Context) - GetUserProfile(c *gin.Context) - UpdateProfile(c *gin.Context) - ChangePassword(c *gin.Context) - ChangeUserPassword(c *gin.Context) - ListTitles(c *gin.Context) - GetActiveUsersForMention(c *gin.Context) -} - -type FileHandler interface { - UploadProfileAvatar(c *gin.Context) - UploadDocument(c *gin.Context) - UploadDocumentFinal(c *gin.Context) -} - -type RBACHandler interface { - CreatePermission(c *gin.Context) - UpdatePermission(c *gin.Context) - DeletePermission(c *gin.Context) - ListPermissions(c *gin.Context) - - CreateRole(c *gin.Context) - UpdateRole(c *gin.Context) - DeleteRole(c *gin.Context) - ListRoles(c *gin.Context) - - // New methods - GetPermissionsGrouped(c *gin.Context) - CreateOrUpdateRole(c *gin.Context) - GetRoleDetail(c *gin.Context) -} - -type MasterHandler interface { - // labels - CreateLabel(c *gin.Context) - UpdateLabel(c *gin.Context) - DeleteLabel(c *gin.Context) - ListLabels(c *gin.Context) - // priorities - CreatePriority(c *gin.Context) - UpdatePriority(c *gin.Context) - DeletePriority(c *gin.Context) - ListPriorities(c *gin.Context) - // institutions - CreateInstitution(c *gin.Context) - UpdateInstitution(c *gin.Context) - DeleteInstitution(c *gin.Context) - ListInstitutions(c *gin.Context) - // disposition actions - CreateDispositionAction(c *gin.Context) - UpdateDispositionAction(c *gin.Context) - DeleteDispositionAction(c *gin.Context) - ListDispositionActions(c *gin.Context) - // departments - ListDepartments(c *gin.Context) - CreateDepartment(c *gin.Context) - GetDepartment(c *gin.Context) - UpdateDepartment(c *gin.Context) - DeleteDepartment(c *gin.Context) - GetOrganizationalChart(c *gin.Context) - GetOrganizationalChartByID(c *gin.Context) -} - -type LetterHandler interface { - CreateIncomingLetter(c *gin.Context) - GetIncomingLetter(c *gin.Context) - ListIncomingLetters(c *gin.Context) - SearchIncomingLetters(c *gin.Context) - GetLetterUnreadCounts(c *gin.Context) - MarkIncomingLetterAsRead(c *gin.Context) - MarkOutgoingLetterAsRead(c *gin.Context) - UpdateIncomingLetter(c *gin.Context) - DeleteIncomingLetter(c *gin.Context) - BulkDeleteIncomingLetters(c *gin.Context) - BulkArchiveIncomingLetters(c *gin.Context) - ArchiveIncomingLetter(c *gin.Context) - - CreateDispositions(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) -} - -type LetterOutgoingHandler interface { - CreateOutgoingLetter(c *gin.Context) - GetOutgoingLetter(c *gin.Context) - ListOutgoingLetters(c *gin.Context) - SearchOutgoingLetters(c *gin.Context) - UpdateOutgoingLetter(c *gin.Context) - DeleteOutgoingLetter(c *gin.Context) - BulkDeleteOutgoingLetters(c *gin.Context) - - SubmitForApproval(c *gin.Context) - ApproveOutgoingLetter(c *gin.Context) - RejectOutgoingLetter(c *gin.Context) - ReviseOutgoingLetter(c *gin.Context) - SendOutgoingLetter(c *gin.Context) - ArchiveOutgoingLetter(c *gin.Context) - GetLetterApprovalInfo(c *gin.Context) - GetLetterApprovals(c *gin.Context) - - AddRecipients(c *gin.Context) - UpdateRecipient(c *gin.Context) - RemoveRecipient(c *gin.Context) - - AddAttachments(c *gin.Context) - RemoveAttachment(c *gin.Context) - - AddFinalAttachments(c *gin.Context) - RemoveFinalAttachment(c *gin.Context) - - 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 { - CreateApprovalFlow(c *gin.Context) - GetApprovalFlow(c *gin.Context) - GetApprovalFlowByDepartment(c *gin.Context) - UpdateApprovalFlow(c *gin.Context) - DeleteApprovalFlow(c *gin.Context) - ListApprovalFlows(c *gin.Context) - ListApprovalFlowsByDepartment(c *gin.Context) - ActivateApprovalFlow(c *gin.Context) - DeactivateApprovalFlow(c *gin.Context) - CloneApprovalFlow(c *gin.Context) -} - -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 { - ProcessCallback(c *gin.Context) - GetEditorConfig(c *gin.Context) - GetOnlyOfficeConfig(c *gin.Context) - LockDocument(c *gin.Context) - UnlockDocument(c *gin.Context) - GetDocumentSession(c *gin.Context) -} - -type AnalyticsHandler interface { - GetDashboard(c *gin.Context) - GetLetterVolume(c *gin.Context) - GetStatusDistribution(c *gin.Context) - GetPriorityDistribution(c *gin.Context) - GetDepartmentStats(c *gin.Context) - 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) -} - -type RepositoryAttachmentHandler interface { - CreateAttachment(c *gin.Context) - DeleteAttachment(c *gin.Context) - GetAttachment(c *gin.Context) - ListAttachment(c *gin.Context) } diff --git a/internal/router/middleware.go b/internal/router/middleware.go index 6828d7e..c3b0fa2 100644 --- a/internal/router/middleware.go +++ b/internal/router/middleware.go @@ -1,13 +1,4 @@ package router -import "github.com/gin-gonic/gin" - type AuthMiddleware interface { - RequireAuth() gin.HandlerFunc - RequireRole(allowedRoles ...string) gin.HandlerFunc - RequireAdminOrManager() gin.HandlerFunc - RequireAdmin() gin.HandlerFunc - RequireSuperAdmin() gin.HandlerFunc - RequireActiveUser() gin.HandlerFunc - RequirePermissions(required ...string) gin.HandlerFunc } diff --git a/internal/router/router.go b/internal/router/router.go index 9d41732..fde9dc9 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1,29 +1,19 @@ package router import ( - "eslogad-be/config" - "eslogad-be/internal/middleware" + "go-backend-template/config" + "go-backend-template/internal/middleware" "github.com/gin-gonic/gin" ) type Router struct { - config *config.Config - authHandler AuthHandler - healthHandler HealthHandler - authMiddleware AuthMiddleware - userHandler UserHandler - fileHandler FileHandler - rbacHandler RBACHandler - masterHandler MasterHandler - letterHandler LetterHandler - letterOutgoingHandler LetterOutgoingHandler - adminApprovalFlowHandler AdminApprovalFlowHandler - dispRouteHandler DispositionRouteHandler - onlyOfficeHandler OnlyOfficeHandler - analyticsHandler AnalyticsHandler - notificationHandler NotificationHandler - repositoryAttachmentHandler RepositoryAttachmentHandler + config *config.Config + authHandler AuthHandler + healthHandler HealthHandler + authMiddleware AuthMiddleware + userHandler UserHandler + dukcapilHandler DukcapilHandler } func NewRouter( @@ -32,35 +22,15 @@ func NewRouter( authMiddleware AuthMiddleware, healthHandler HealthHandler, userHandler UserHandler, - fileHandler FileHandler, - rbacHandler RBACHandler, - masterHandler MasterHandler, - letterHandler LetterHandler, - letterOutgoingHandler LetterOutgoingHandler, - adminApprovalFlowHandler AdminApprovalFlowHandler, - dispRouteHandler DispositionRouteHandler, - onlyOfficeHandler OnlyOfficeHandler, - analyticsHandler AnalyticsHandler, - notificationHandler NotificationHandler, - repositoryAttachmentHandler RepositoryAttachmentHandler, + dukcapilHandler DukcapilHandler, ) *Router { return &Router{ - config: cfg, - authHandler: authHandler, - authMiddleware: authMiddleware, - healthHandler: healthHandler, - userHandler: userHandler, - fileHandler: fileHandler, - rbacHandler: rbacHandler, - masterHandler: masterHandler, - letterHandler: letterHandler, - letterOutgoingHandler: letterOutgoingHandler, - adminApprovalFlowHandler: adminApprovalFlowHandler, - dispRouteHandler: dispRouteHandler, - onlyOfficeHandler: onlyOfficeHandler, - analyticsHandler: analyticsHandler, - notificationHandler: notificationHandler, - repositoryAttachmentHandler: repositoryAttachmentHandler, + config: cfg, + authHandler: authHandler, + authMiddleware: authMiddleware, + healthHandler: healthHandler, + userHandler: userHandler, + dukcapilHandler: dukcapilHandler, } } @@ -85,235 +55,17 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { v1 := rg.Group("/api/v1") { - auth := v1.Group("/auth") - { - auth.POST("/login", r.authHandler.Login) - auth.POST("/refresh", r.authHandler.RefreshToken) - auth.GET("/profile", r.authHandler.GetProfile) - } - - users := v1.Group("/users") - users.Use(r.authMiddleware.RequireAuth()) - { - users.POST("", r.userHandler.CreateUser) - users.GET("", r.userHandler.ListUsers) - users.PUT("/:id", r.userHandler.UpdateUser) - users.DELETE("/:id", r.userHandler.DeleteUser) - users.GET("/profile", r.userHandler.GetProfile) - users.GET("/:id/profile", r.userHandler.GetUserProfile) - users.PUT("/profile", r.userHandler.UpdateProfile) - users.PUT("/:id/password", r.userHandler.ChangePassword) - users.PUT("/:id/user-password", r.userHandler.ChangeUserPassword) - users.GET("/titles", r.userHandler.ListTitles) - users.GET("/mention", r.userHandler.GetActiveUsersForMention) - users.POST("/profile/avatar", r.fileHandler.UploadProfileAvatar) - } - - files := v1.Group("/files") - files.Use(r.authMiddleware.RequireAuth()) - { - files.POST("/documents", r.fileHandler.UploadDocument) - files.POST("/documents/final", r.fileHandler.UploadDocumentFinal) - } - - rbac := v1.Group("/rbac") - rbac.Use(r.authMiddleware.RequireAuth()) - { - rbac.GET("/permissions", r.rbacHandler.ListPermissions) - rbac.POST("/permissions", r.rbacHandler.CreatePermission) - rbac.PUT("/permissions/:id", r.rbacHandler.UpdatePermission) - rbac.DELETE("/permissions/:id", r.rbacHandler.DeletePermission) - - rbac.GET("/roles", r.rbacHandler.ListRoles) - rbac.POST("/roles", r.rbacHandler.CreateRole) - rbac.PUT("/roles/:id", r.rbacHandler.UpdateRole) - rbac.DELETE("/roles/:id", r.rbacHandler.DeleteRole) - } - - roles := v1.Group("/roles") - roles.Use(r.authMiddleware.RequireAuth()) - { - roles.POST("", r.rbacHandler.CreateOrUpdateRole) - roles.GET("/permissions", r.rbacHandler.GetPermissionsGrouped) - roles.GET("/:id", r.rbacHandler.GetRoleDetail) - } - - master := v1.Group("/master") - master.Use(r.authMiddleware.RequireAuth()) - { - master.GET("/labels", r.masterHandler.ListLabels) - master.POST("/labels", r.masterHandler.CreateLabel) - master.PUT("/labels/:id", r.masterHandler.UpdateLabel) - master.DELETE("/labels/:id", r.masterHandler.DeleteLabel) - - master.GET("/priorities", r.masterHandler.ListPriorities) - master.POST("/priorities", r.masterHandler.CreatePriority) - master.PUT("/priorities/:id", r.masterHandler.UpdatePriority) - master.DELETE("/priorities/:id", r.masterHandler.DeletePriority) - - master.GET("/institutions", r.masterHandler.ListInstitutions) - master.POST("/institutions", r.masterHandler.CreateInstitution) - master.PUT("/institutions/:id", r.masterHandler.UpdateInstitution) - master.DELETE("/institutions/:id", r.masterHandler.DeleteInstitution) - - master.GET("/disposition-actions", r.masterHandler.ListDispositionActions) - master.POST("/disposition-actions", r.masterHandler.CreateDispositionAction) - master.PUT("/disposition-actions/:id", r.masterHandler.UpdateDispositionAction) - master.DELETE("/disposition-actions/:id", r.masterHandler.DeleteDispositionAction) - - master.POST("/departments", r.masterHandler.CreateDepartment) - master.GET("/departments", r.masterHandler.ListDepartments) - master.GET("/departments/chart", r.masterHandler.GetOrganizationalChart) - master.GET("/departments/:id", r.masterHandler.GetDepartment) - master.GET("/departments/:id/chart", r.masterHandler.GetOrganizationalChartByID) - master.PUT("/departments/:id", r.masterHandler.UpdateDepartment) - master.DELETE("/departments/:id", r.masterHandler.DeleteDepartment) - } - - lettersch := v1.Group("/letters") - lettersch.Use(r.authMiddleware.RequireAuth()) - { - lettersch.GET("/unread-counts", r.letterHandler.GetLetterUnreadCounts) - - lettersch.GET("/incoming", r.letterHandler.ListIncomingLetters) - lettersch.GET("/incoming/search", r.letterHandler.SearchIncomingLetters) - 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.DELETE("/incoming/delete", r.letterHandler.BulkDeleteIncomingLetters) - lettersch.POST("/incoming/archive", r.letterHandler.BulkArchiveIncomingLetters) - lettersch.PUT("/incoming/:id/archive", r.letterHandler.ArchiveIncomingLetter) - - lettersch.POST("/outgoing", r.letterOutgoingHandler.CreateOutgoingLetter) - lettersch.GET("/outgoing/search", r.letterOutgoingHandler.SearchOutgoingLetters) - 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.DELETE("/outgoing/delete", r.letterOutgoingHandler.BulkDeleteOutgoingLetters) - - lettersch.POST("/outgoing/:id/submit", r.letterOutgoingHandler.SubmitForApproval) - lettersch.POST("/outgoing/:id/approve", r.letterOutgoingHandler.ApproveOutgoingLetter) - lettersch.POST("/outgoing/:id/reject", r.letterOutgoingHandler.RejectOutgoingLetter) - lettersch.POST("/outgoing/:id/revise", r.letterOutgoingHandler.ReviseOutgoingLetter) - 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) - - lettersch.POST("/outgoing/:id/recipients", r.letterOutgoingHandler.AddRecipients) - lettersch.PUT("/outgoing/:id/recipients/:recipient_id", r.letterOutgoingHandler.UpdateRecipient) - lettersch.DELETE("/outgoing/:id/recipients/:recipient_id", r.letterOutgoingHandler.RemoveRecipient) - - lettersch.POST("/outgoing/:id/attachments", r.letterOutgoingHandler.AddAttachments) - lettersch.DELETE("/outgoing/:id/attachments/:attachment_id", r.letterOutgoingHandler.RemoveAttachment) - - lettersch.POST("/outgoing/:id/attachments/final", r.letterOutgoingHandler.AddFinalAttachments) - lettersch.DELETE("/outgoing/:id/attachments/final/:attachment_id", r.letterOutgoingHandler.RemoveFinalAttachment) - - lettersch.POST("/outgoing/:id/discussions", r.letterOutgoingHandler.CreateDiscussion) - lettersch.PUT("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.UpdateDiscussion) - lettersch.DELETE("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.DeleteDiscussion) - - lettersch.GET("/outgoing/:id/approval-discussions", r.letterOutgoingHandler.GetApprovalDiscussions) - lettersch.GET("/outgoing/:id/timeline", r.letterOutgoingHandler.GetApprovalTimeline) - - 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) - } - - droutes := v1.Group("/disposition-routes") - droutes.Use(r.authMiddleware.RequireAuth()) - { - 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) - } - - repoattachsch := v1.Group("/repository-attachments") - repoattachsch.Use(r.authMiddleware.RequireAuth()) - { - repoattachsch.POST("", r.repositoryAttachmentHandler.CreateAttachment) - repoattachsch.GET("", r.repositoryAttachmentHandler.ListAttachment) - repoattachsch.DELETE("/:id", r.repositoryAttachmentHandler.DeleteAttachment) - repoattachsch.GET("/:id", r.repositoryAttachmentHandler.GetAttachment) - } - - admin := v1.Group("/setting") - admin.Use(r.authMiddleware.RequireAuth()) - { - approvalFlows := admin.Group("/approval-flows") + if r.authHandler != nil { + auth := v1.Group("/auth") { - approvalFlows.POST("", r.adminApprovalFlowHandler.CreateApprovalFlow) - approvalFlows.GET("", r.adminApprovalFlowHandler.ListApprovalFlows) - approvalFlows.GET("/department", r.adminApprovalFlowHandler.ListApprovalFlowsByDepartment) - approvalFlows.GET("/:id", r.adminApprovalFlowHandler.GetApprovalFlow) - approvalFlows.GET("/department/:department_id", r.adminApprovalFlowHandler.GetApprovalFlowByDepartment) - approvalFlows.PUT("/:id", r.adminApprovalFlowHandler.UpdateApprovalFlow) - approvalFlows.DELETE("/:id", r.adminApprovalFlowHandler.DeleteApprovalFlow) - approvalFlows.POST("/:id/activate", r.adminApprovalFlowHandler.ActivateApprovalFlow) - approvalFlows.POST("/:id/deactivate", r.adminApprovalFlowHandler.DeactivateApprovalFlow) - approvalFlows.POST("/:id/clone", r.adminApprovalFlowHandler.CloneApprovalFlow) + auth.POST("/login", r.authHandler.Login) } } - onlyoffice := v1.Group("/onlyoffice") - { - onlyoffice.POST("/callback/:key", r.onlyOfficeHandler.ProcessCallback) - - onlyofficeAuth := onlyoffice.Group("") - onlyofficeAuth.Use(r.authMiddleware.RequireAuth()) + if r.dukcapilHandler != nil { + dukcapil := v1.Group("/dukcapil") { - onlyofficeAuth.POST("/config", r.onlyOfficeHandler.GetEditorConfig) - onlyofficeAuth.GET("/settings", r.onlyOfficeHandler.GetOnlyOfficeConfig) - onlyofficeAuth.POST("/lock/:id", r.onlyOfficeHandler.LockDocument) - onlyofficeAuth.POST("/unlock/:id", r.onlyOfficeHandler.UnlockDocument) - onlyofficeAuth.GET("/session/:key", r.onlyOfficeHandler.GetDocumentSession) - } - } - - analytics := v1.Group("/analytics") - analytics.Use(r.authMiddleware.RequireAuth()) - { - analytics.GET("/dashboard", r.analyticsHandler.GetDashboard) - analytics.GET("/volume", r.analyticsHandler.GetLetterVolume) - analytics.GET("/status-distribution", r.analyticsHandler.GetStatusDistribution) - analytics.GET("/priority-distribution", r.analyticsHandler.GetPriorityDistribution) - analytics.GET("/department-stats", r.analyticsHandler.GetDepartmentStats) - 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) + dukcapil.POST("/face-match", r.dukcapilHandler.FaceMatch) } } } diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go deleted file mode 100644 index 28f08d3..0000000 --- a/internal/service/analytics_service.go +++ /dev/null @@ -1,415 +0,0 @@ -package service - -import ( - "context" - "fmt" - "time" - - "eslogad-be/internal/appcontext" - "eslogad-be/internal/contract" - "eslogad-be/internal/repository" - - "github.com/google/uuid" -) - -type AnalyticsService interface { - GetDashboard(ctx context.Context, req *contract.AnalyticsDashboardRequest) (*contract.AnalyticsDashboardResponse, error) - GetLetterVolume(ctx context.Context) (*contract.LetterVolumeByTypeResponse, error) -} - -type AnalyticsServiceImpl struct { - analyticsRepo *repository.AnalyticsRepository -} - -func NewAnalyticsService(analyticsRepo *repository.AnalyticsRepository) *AnalyticsServiceImpl { - return &AnalyticsServiceImpl{ - analyticsRepo: analyticsRepo, - } -} - -func (s *AnalyticsServiceImpl) GetDashboard(ctx context.Context, req *contract.AnalyticsDashboardRequest) (*contract.AnalyticsDashboardResponse, error) { - // Parse dates - var startDate, endDate time.Time - if req.StartDate != "" { - if date, err := time.Parse("2006-01-02", req.StartDate); err == nil { - startDate = date - } - } else { - // Default to last 30 days - startDate = time.Now().AddDate(0, 0, -30) - } - - if req.EndDate != "" { - if date, err := time.Parse("2006-01-02", req.EndDate); err == nil { - endDate = date.Add(23*time.Hour + 59*time.Minute + 59*time.Second) - } - } else { - endDate = time.Now() - } - - // Apply user context filters if not admin - var userID *uuid.UUID - - appCtx := appcontext.FromGinContext(ctx) - if appCtx != nil && appCtx.UserRole != "admin" && appCtx.UserRole != "superadmin" { - userID = &appCtx.UserID - } - - response := &contract.AnalyticsDashboardResponse{} - - // Get summary statistics - don't filter by department for overall stats - summaryData, err := s.analyticsRepo.GetLetterSummaryStats(ctx, startDate, endDate, userID, nil) - if err != nil { - return nil, err - } - fmt.Printf("[DEBUG] summaryData: %v\n", summaryData) - response.Summary = s.mapSummaryStats(summaryData) - - // Calculate growth metrics - response.Summary.WeekOverWeekGrowth = s.calculateWeekOverWeekGrowth(ctx) - response.Summary.MonthOverMonthGrowth = s.calculateMonthOverMonthGrowth(ctx) - - // Get priority distribution - priorityData, err := s.analyticsRepo.GetPriorityDistribution(ctx, startDate, endDate) - if err != nil { - return nil, err - } - response.PriorityDistribution = s.mapPriorityDistribution(priorityData) - - // Get department statistics - deptData, err := s.analyticsRepo.GetDepartmentStats(ctx, startDate, endDate) - if err != nil { - return nil, err - } - response.DepartmentStats = s.mapDepartmentStats(deptData) - - // Get monthly trend (last 12 months) - monthlyData, err := s.analyticsRepo.GetMonthlyTrendByUserID(ctx, userID, 12) - if err != nil { - return nil, err - } - response.MonthlyTrend = s.mapMonthlyTrend(monthlyData) - - // Get simplified department stats (departments_stats) - response.DepartmentsStats = s.getSimpleDepartmentStats(ctx, startDate, endDate) - - // Get institution statistics - instData, err := s.analyticsRepo.GetInstitutionStats(ctx, startDate, endDate) - if err != nil { - return nil, err - } - response.InstitutionStats = s.mapInstitutionStats(instData) - - // Get daily activity (last 7 days) - dailyData, err := s.analyticsRepo.GetDailyActivityByUserID(ctx, userID, 7) - if err != nil { - return nil, err - } - response.DailyActivity = s.mapDailyActivity(dailyData) - - return response, nil -} - -func (s *AnalyticsServiceImpl) GetLetterVolume(ctx context.Context) (*contract.LetterVolumeByTypeResponse, error) { - // This would be implemented with specific queries for volume metrics - // For now, returning a placeholder - return &contract.LetterVolumeByTypeResponse{ - Incoming: contract.IncomingLetterVolume{ - Today: 0, - ThisWeek: 0, - ThisMonth: 0, - ThisYear: 0, - Total: 0, - }, - Outgoing: contract.OutgoingLetterVolume{ - Today: 0, - ThisWeek: 0, - ThisMonth: 0, - ThisYear: 0, - Total: 0, - }, - }, nil -} - -// Helper functions to map repository data to contract types - -func (s *AnalyticsServiceImpl) mapSummaryStats(data map[string]interface{}) contract.LetterSummaryStats { - return contract.LetterSummaryStats{ - TotalIncoming: getInt64Value(data["total_incoming"]), - TotalOutgoing: getInt64Value(data["total_outgoing"]), - } -} - -// calculateWeekOverWeekGrowth calculates the week over week growth rate -func (s *AnalyticsServiceImpl) calculateWeekOverWeekGrowth(ctx context.Context) float64 { - // Get this week's data - thisWeekStart := time.Now().AddDate(0, 0, -int(time.Now().Weekday())) - thisWeekEnd := time.Now() - - // Get last week's data - lastWeekStart := thisWeekStart.AddDate(0, 0, -7) - lastWeekEnd := thisWeekStart.AddDate(0, 0, -1) - - thisWeekData, _ := s.analyticsRepo.GetLetterSummaryStats(ctx, thisWeekStart, thisWeekEnd, nil, nil) - lastWeekData, _ := s.analyticsRepo.GetLetterSummaryStats(ctx, lastWeekStart, lastWeekEnd, nil, nil) - - thisWeekTotal := getInt64Value(thisWeekData["total_incoming"]) + getInt64Value(thisWeekData["total_outgoing"]) - lastWeekTotal := getInt64Value(lastWeekData["total_incoming"]) + getInt64Value(lastWeekData["total_outgoing"]) - - if lastWeekTotal > 0 { - return float64((thisWeekTotal - lastWeekTotal) * 100 / lastWeekTotal) - } - return 0 -} - -// calculateMonthOverMonthGrowth calculates the month over month growth rate -func (s *AnalyticsServiceImpl) calculateMonthOverMonthGrowth(ctx context.Context) float64 { - // Get this month's data - now := time.Now() - thisMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) - thisMonthEnd := now - - // Get last month's data - lastMonthStart := thisMonthStart.AddDate(0, -1, 0) - lastMonthEnd := thisMonthStart.AddDate(0, 0, -1) - - thisMonthData, _ := s.analyticsRepo.GetLetterSummaryStats(ctx, thisMonthStart, thisMonthEnd, nil, nil) - lastMonthData, _ := s.analyticsRepo.GetLetterSummaryStats(ctx, lastMonthStart, lastMonthEnd, nil, nil) - - thisMonthTotal := getInt64Value(thisMonthData["total_incoming"]) + getInt64Value(thisMonthData["total_outgoing"]) - lastMonthTotal := getInt64Value(lastMonthData["total_incoming"]) + getInt64Value(lastMonthData["total_outgoing"]) - - if lastMonthTotal > 0 { - return float64((thisMonthTotal - lastMonthTotal) * 100 / lastMonthTotal) - } - return 0 -} - -// getSimpleDepartmentStats gets simplified department statistics -func (s *AnalyticsServiceImpl) getSimpleDepartmentStats(ctx context.Context, startDate, endDate time.Time) []contract.SimpleDepartmentStats { - // Get department stats with letter counts - deptData, err := s.analyticsRepo.GetDepartmentStats(ctx, startDate, endDate) - if err != nil { - return []contract.SimpleDepartmentStats{} - } - - result := make([]contract.SimpleDepartmentStats, 0, len(deptData)) - for _, item := range deptData { - deptIDStr := getStringValue(item["department_id"]) - deptID, err := uuid.Parse(deptIDStr) - if err != nil { - continue - } - - // Calculate total letter count (incoming + outgoing) - letterCount := getInt64Value(item["incoming_count"]) + getInt64Value(item["outgoing_count"]) - - result = append(result, contract.SimpleDepartmentStats{ - DepartmentID: deptID, - Department: getStringValue(item["department_name"]), - LetterCount: letterCount, - }) - } - - return result -} - -func (s *AnalyticsServiceImpl) mapStatusDistribution(data []map[string]interface{}) []contract.StatusDistribution { - result := make([]contract.StatusDistribution, 0, len(data)) - for _, item := range data { - result = append(result, contract.StatusDistribution{ - Status: getStringValue(item["status"]), - Count: getInt64Value(item["count"]), - Percentage: getFloat64Value(item["percentage"]), - Type: getStringValue(item["type"]), - }) - } - return result -} - -func (s *AnalyticsServiceImpl) mapPriorityDistribution(data []map[string]interface{}) []contract.PriorityDistribution { - result := make([]contract.PriorityDistribution, 0, len(data)) - for _, item := range data { - result = append(result, contract.PriorityDistribution{ - PriorityID: getStringValue(item["priority_id"]), - PriorityName: getStringValue(item["priority_name"]), - Level: getIntValue(item["level"]), - Count: getInt64Value(item["count"]), - Percentage: getFloat64Value(item["percentage"]), - AvgResponseTime: getFloat64Value(item["avg_response_time"]), - }) - } - return result -} - -func (s *AnalyticsServiceImpl) mapDepartmentStats(data []map[string]interface{}) []contract.DepartmentStats { - result := make([]contract.DepartmentStats, 0, len(data)) - for _, item := range data { - if deptID, err := uuid.Parse(getStringValue(item["department_id"])); err == nil { - result = append(result, contract.DepartmentStats{ - DepartmentID: deptID, - DepartmentName: getStringValue(item["department_name"]), - DepartmentCode: getStringValue(item["department_code"]), - IncomingCount: getInt64Value(item["incoming_count"]), - OutgoingCount: getInt64Value(item["outgoing_count"]), - PendingCount: getInt64Value(item["pending_count"]), - AvgResponseTime: getFloat64Value(item["avg_response_time"]), - CompletionRate: getFloat64Value(item["completion_rate"]), - }) - } - } - return result -} - -func (s *AnalyticsServiceImpl) mapMonthlyTrend(data []map[string]interface{}) []contract.MonthlyTrend { - result := make([]contract.MonthlyTrend, 0, len(data)) - for _, item := range data { - result = append(result, contract.MonthlyTrend{ - Month: getStringValue(item["month"]), - Year: getIntValue(item["year"]), - IncomingCount: getInt64Value(item["incoming_count"]), - OutgoingCount: getInt64Value(item["outgoing_count"]), - TotalCount: getInt64Value(item["total_count"]), - GrowthRate: getFloat64Value(item["growth_rate"]), - }) - } - return result -} - -func (s *AnalyticsServiceImpl) mapTopUsers(data []map[string]interface{}) []contract.TopUserStats { - result := make([]contract.TopUserStats, 0, len(data)) - for _, item := range data { - if userID, err := uuid.Parse(getStringValue(item["user_id"])); err == nil { - result = append(result, contract.TopUserStats{ - UserID: userID, - UserName: getStringValue(item["user_name"]), - UserEmail: getStringValue(item["user_email"]), - Department: getStringValue(item["department"]), - LetterCount: getInt64Value(item["letter_count"]), - AvgResponseTime: getFloat64Value(item["avg_response_time"]), - }) - } - } - return result -} - -func (s *AnalyticsServiceImpl) mapInstitutionStats(data []map[string]interface{}) []contract.InstitutionStats { - result := make([]contract.InstitutionStats, 0, len(data)) - for _, item := range data { - if instID, err := uuid.Parse(getStringValue(item["institution_id"])); err == nil { - stat := contract.InstitutionStats{ - InstitutionID: instID, - InstitutionName: getStringValue(item["institution_name"]), - InstitutionType: getStringValue(item["institution_type"]), - IncomingCount: getInt64Value(item["incoming_count"]), - OutgoingCount: getInt64Value(item["outgoing_count"]), - TotalCount: getInt64Value(item["total_count"]), - } - - if lastActivity, ok := item["last_activity"].(time.Time); ok { - stat.LastActivity = lastActivity - } - - result = append(result, stat) - } - } - return result -} - -func (s *AnalyticsServiceImpl) mapApprovalMetrics(data map[string]interface{}) contract.ApprovalMetrics { - return contract.ApprovalMetrics{ - TotalSubmitted: getInt64Value(data["total_submitted"]), - TotalApproved: getInt64Value(data["total_approved"]), - TotalRejected: getInt64Value(data["total_rejected"]), - TotalPending: getInt64Value(data["total_pending"]), - ApprovalRate: getFloat64Value(data["approval_rate"]), - RejectionRate: getFloat64Value(data["rejection_rate"]), - AvgApprovalTime: getFloat64Value(data["avg_approval_time"]), - AvgApprovalSteps: getFloat64Value(data["avg_approval_steps"]), - } -} - -func (s *AnalyticsServiceImpl) mapResponseTimeStats(data map[string]interface{}) contract.ResponseTimeStats { - return contract.ResponseTimeStats{ - MinResponseTime: getFloat64Value(data["min_response_time"]), - MaxResponseTime: getFloat64Value(data["max_response_time"]), - AvgResponseTime: getFloat64Value(data["avg_response_time"]), - MedianResponseTime: getFloat64Value(data["median_response_time"]), - P95ResponseTime: getFloat64Value(data["p95_response_time"]), - P99ResponseTime: getFloat64Value(data["p99_response_time"]), - } -} - -func (s *AnalyticsServiceImpl) mapDailyActivity(data []map[string]interface{}) []contract.DailyActivity { - result := make([]contract.DailyActivity, 0, len(data)) - for _, item := range data { - activity := contract.DailyActivity{ - Date: "", // Leave date empty as per requirement - DayOfWeek: getStringValue(item["day_of_week"]), - IncomingCount: getInt64Value(item["incoming_count"]), - OutgoingCount: getInt64Value(item["outgoing_count"]), - } - result = append(result, activity) - } - return result -} - -// Helper functions to safely extract values from interface{} - -func getStringValue(v interface{}) string { - if v == nil { - return "" - } - if str, ok := v.(string); ok { - return str - } - return "" -} - -func getInt64Value(v interface{}) int64 { - if v == nil { - return 0 - } - switch val := v.(type) { - case int64: - return val - case float64: - return int64(val) - case int: - return int64(val) - default: - return 0 - } -} - -func getIntValue(v interface{}) int { - if v == nil { - return 0 - } - switch val := v.(type) { - case int: - return val - case int64: - return int(val) - case float64: - return int(val) - default: - return 0 - } -} - -func getFloat64Value(v interface{}) float64 { - if v == nil { - return 0 - } - switch val := v.(type) { - case float64: - return val - case int64: - return float64(val) - case int: - return float64(val) - default: - return 0 - } -} diff --git a/internal/service/approval_flow_service.go b/internal/service/approval_flow_service.go deleted file mode 100644 index 23e8b61..0000000 --- a/internal/service/approval_flow_service.go +++ /dev/null @@ -1,263 +0,0 @@ -package service - -import ( - "context" - - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type ApprovalFlowService interface { - CreateApprovalFlow(ctx context.Context, req *contract.ApprovalFlowRequest) (*contract.ApprovalFlowResponse, error) - GetApprovalFlow(ctx context.Context, id uuid.UUID) (*contract.ApprovalFlowResponse, error) - GetApprovalFlowByDepartment(ctx context.Context, departmentID uuid.UUID) (*contract.ApprovalFlowResponse, error) - UpdateApprovalFlow(ctx context.Context, id uuid.UUID, req *contract.ApprovalFlowRequest) (*contract.ApprovalFlowResponse, error) - DeleteApprovalFlow(ctx context.Context, id uuid.UUID) error - ListApprovalFlows(ctx context.Context, req *contract.ListApprovalFlowsRequest) (*contract.ListApprovalFlowsResponse, error) -} - -type ApprovalFlowServiceImpl struct { - db *gorm.DB - flowRepo *repository.ApprovalFlowRepository - stepRepo *repository.ApprovalFlowStepRepository - txManager *repository.TxManager -} - -func NewApprovalFlowService( - db *gorm.DB, - flowRepo *repository.ApprovalFlowRepository, - stepRepo *repository.ApprovalFlowStepRepository, - txManager *repository.TxManager, -) *ApprovalFlowServiceImpl { - return &ApprovalFlowServiceImpl{ - db: db, - flowRepo: flowRepo, - stepRepo: stepRepo, - txManager: txManager, - } -} - -func (s *ApprovalFlowServiceImpl) CreateApprovalFlow(ctx context.Context, req *contract.ApprovalFlowRequest) (*contract.ApprovalFlowResponse, error) { - flow := &entities.ApprovalFlow{ - DepartmentID: req.DepartmentID, - Name: req.Name, - Description: req.Description, - IsActive: req.IsActive, - } - - err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - if err := s.flowRepo.Create(txCtx, flow); err != nil { - return err - } - - if len(req.Steps) > 0 { - steps := make([]entities.ApprovalFlowStep, len(req.Steps)) - for i, stepReq := range req.Steps { - if stepReq.ApproverRoleID == nil && stepReq.ApproverUserID == nil { - return gorm.ErrInvalidData - } - - steps[i] = entities.ApprovalFlowStep{ - FlowID: flow.ID, - StepOrder: stepReq.StepOrder, - ParallelGroup: stepReq.ParallelGroup, - ApproverRoleID: stepReq.ApproverRoleID, - ApproverUserID: stepReq.ApproverUserID, - Required: stepReq.Required, - } - } - - if err := s.stepRepo.CreateBulk(txCtx, steps); err != nil { - return err - } - } - - return nil - }) - - if err != nil { - return nil, err - } - - result, err := s.flowRepo.Get(ctx, flow.ID) - if err != nil { - return nil, err - } - - return transformApprovalFlowToResponse(result), nil -} - -func (s *ApprovalFlowServiceImpl) GetApprovalFlow(ctx context.Context, id uuid.UUID) (*contract.ApprovalFlowResponse, error) { - flow, err := s.flowRepo.Get(ctx, id) - if err != nil { - return nil, err - } - - return transformApprovalFlowToResponse(flow), nil -} - -func (s *ApprovalFlowServiceImpl) GetApprovalFlowByDepartment(ctx context.Context, departmentID uuid.UUID) (*contract.ApprovalFlowResponse, error) { - flow, err := s.flowRepo.GetByDepartment(ctx, departmentID) - if err != nil { - return nil, err - } - - return transformApprovalFlowToResponse(flow), nil -} - -func (s *ApprovalFlowServiceImpl) UpdateApprovalFlow(ctx context.Context, id uuid.UUID, req *contract.ApprovalFlowRequest) (*contract.ApprovalFlowResponse, error) { - flow, err := s.flowRepo.Get(ctx, id) - if err != nil { - return nil, err - } - - flow.DepartmentID = req.DepartmentID - flow.Name = req.Name - flow.Description = req.Description - flow.IsActive = req.IsActive - - err = s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - if err := s.flowRepo.Update(txCtx, flow); err != nil { - return err - } - - if err := s.stepRepo.DeleteByFlow(txCtx, id); err != nil { - return err - } - - if len(req.Steps) > 0 { - steps := make([]entities.ApprovalFlowStep, len(req.Steps)) - for i, stepReq := range req.Steps { - if stepReq.ApproverRoleID == nil && stepReq.ApproverUserID == nil { - return gorm.ErrInvalidData - } - - steps[i] = entities.ApprovalFlowStep{ - FlowID: flow.ID, - StepOrder: stepReq.StepOrder, - ParallelGroup: stepReq.ParallelGroup, - ApproverRoleID: stepReq.ApproverRoleID, - ApproverUserID: stepReq.ApproverUserID, - Required: stepReq.Required, - } - } - - if err := s.stepRepo.CreateBulk(txCtx, steps); err != nil { - return err - } - } - - return nil - }) - - if err != nil { - return nil, err - } - - result, err := s.flowRepo.Get(ctx, id) - if err != nil { - return nil, err - } - - return transformApprovalFlowToResponse(result), nil -} - -func (s *ApprovalFlowServiceImpl) DeleteApprovalFlow(ctx context.Context, id uuid.UUID) error { - return s.flowRepo.Delete(ctx, id) -} - -func (s *ApprovalFlowServiceImpl) ListApprovalFlows(ctx context.Context, req *contract.ListApprovalFlowsRequest) (*contract.ListApprovalFlowsResponse, error) { - filter := repository.ListApprovalFlowsFilter{ - DepartmentID: req.DepartmentID, - Search: req.Search, - IsActive: req.IsActive, - } - - page := req.Page - if page <= 0 { - page = 1 - } - - limit := req.Limit - if limit <= 0 { - limit = 10 - } - if limit > 100 { - limit = 100 // Max limit to prevent performance issues - } - - offset := (page - 1) * limit - - flows, total, err := s.flowRepo.List(ctx, filter, limit, offset) - if err != nil { - return nil, err - } - - items := make([]*contract.ApprovalFlowResponse, len(flows)) - for i, flow := range flows { - items[i] = transformApprovalFlowToResponse(&flow) - } - - return &contract.ListApprovalFlowsResponse{ - Items: items, - Total: total, - }, nil -} - -func transformApprovalFlowToResponse(flow *entities.ApprovalFlow) *contract.ApprovalFlowResponse { - resp := &contract.ApprovalFlowResponse{ - ID: flow.ID, - DepartmentID: flow.DepartmentID, - Name: flow.Name, - Description: flow.Description, - IsActive: flow.IsActive, - CreatedAt: flow.CreatedAt, - UpdatedAt: flow.UpdatedAt, - } - - if flow.Department != nil { - resp.Department = &contract.DepartmentResponse{ - ID: flow.Department.ID, - Name: flow.Department.Name, - } - } - - if len(flow.Steps) > 0 { - resp.Steps = make([]contract.ApprovalFlowStepResponse, len(flow.Steps)) - for i, step := range flow.Steps { - stepResp := contract.ApprovalFlowStepResponse{ - ID: step.ID, - StepOrder: step.StepOrder, - ParallelGroup: step.ParallelGroup, - ApproverRoleID: step.ApproverRoleID, - ApproverUserID: step.ApproverUserID, - Required: step.Required, - CreatedAt: step.CreatedAt, - UpdatedAt: step.UpdatedAt, - } - - if step.ApproverRole != nil { - stepResp.ApproverRole = &contract.RoleResponse{ - ID: step.ApproverRole.ID, - Name: step.ApproverRole.Name, - } - } - - if step.ApproverUser != nil { - stepResp.ApproverUser = &contract.UserResponse{ - ID: step.ApproverUser.ID, - Name: step.ApproverUser.Name, - Email: step.ApproverUser.Email, - } - } - - resp.Steps[i] = stepResp - } - } - - return resp -} \ No newline at end of file diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index eba0c4e..a36fec5 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -3,10 +3,12 @@ package service import ( "context" "errors" - "fmt" "time" - "eslogad-be/internal/contract" + "go-backend-template/internal/contract" + "go-backend-template/internal/entities" + "go-backend-template/internal/repository" + "go-backend-template/internal/transformer" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" @@ -14,163 +16,101 @@ import ( ) type AuthServiceImpl struct { - userProcessor UserProcessor - jwtSecret string - tokenTTL time.Duration + userRepo *repository.UserRepositoryImpl + jwtSecret string } -type Claims struct { - UserID uuid.UUID `json:"user_id"` - Email string `json:"email"` - Role string `json:"role"` - Roles []string `json:"roles"` - Permissions []string `json:"permissions"` - jwt.RegisteredClaims -} - -func NewAuthService(userProcessor UserProcessor, jwtSecret string) *AuthServiceImpl { +func NewAuthService(userRepo *repository.UserRepositoryImpl, jwtSecret string) *AuthServiceImpl { return &AuthServiceImpl{ - userProcessor: userProcessor, - jwtSecret: jwtSecret, - tokenTTL: 24 * time.Hour, + userRepo: userRepo, + jwtSecret: jwtSecret, } } func (s *AuthServiceImpl) Login(ctx context.Context, req *contract.LoginRequest) (*contract.LoginResponse, error) { - userResponse, err := s.userProcessor.GetUserByEmail(ctx, req.Email) + user, err := s.userRepo.GetByEmail(ctx, req.Email) if err != nil { - return nil, fmt.Errorf("invalid credentials") + return nil, errors.New("invalid credentials") } - if !userResponse.IsActive { - return nil, fmt.Errorf("user account is deactivated") + if !user.IsActive { + return nil, errors.New("user account is inactive") } - userEntity, err := s.userProcessor.GetUserEntityByEmail(ctx, req.Email) + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + return nil, errors.New("invalid credentials") + } + + token, err := s.generateToken(user) if err != nil { - return nil, fmt.Errorf("invalid credentials") + return nil, err } - err = bcrypt.CompareHashAndPassword([]byte(userEntity.PasswordHash), []byte(req.Password)) - if err != nil { - return nil, fmt.Errorf("invalid credentials") - } - - roles, _ := s.userProcessor.GetUserRoles(ctx, userResponse.ID) - permCodes, _ := s.userProcessor.GetUserPermissionCodes(ctx, userResponse.ID) - // Departments are now preloaded, so they're already in userResponse - - token, expiresAt, err := s.generateToken(userResponse, roles, permCodes) - if err != nil { - return nil, fmt.Errorf("failed to generate token: %w", err) - } + expiresAt := time.Now().Add(24 * time.Hour) return &contract.LoginResponse{ - Token: token, - ExpiresAt: expiresAt, - User: *userResponse, - Roles: roles, - Permissions: permCodes, - Departments: userResponse.DepartmentResponse, + Token: token, + ExpiresAt: expiresAt, + User: transformer.EntityToContract(user), }, nil } -func (s *AuthServiceImpl) ValidateToken(tokenString string) (*contract.UserResponse, error) { - claims, err := s.parseToken(tokenString) +func (s *AuthServiceImpl) RefreshToken(ctx context.Context, req *contract.RefreshTokenRequest) (*contract.LoginResponse, error) { + claims, err := s.ValidateToken(req.RefreshToken) if err != nil { - return nil, fmt.Errorf("invalid token: %w", err) + return nil, errors.New("invalid refresh token") } - userResponse, err := s.userProcessor.GetUserByID(context.Background(), claims.UserID) + userID, err := uuid.Parse(claims.Subject) if err != nil { - return nil, fmt.Errorf("user not found: %w", err) + return nil, errors.New("invalid user ID in token") } - if !userResponse.IsActive { - return nil, fmt.Errorf("user account is deactivated") - } - - // Note: Departments are not loaded in light version, add if needed - return userResponse, nil -} - -func (s *AuthServiceImpl) RefreshToken(ctx context.Context, tokenString string) (*contract.LoginResponse, error) { - claims, err := s.parseToken(tokenString) + user, err := s.userRepo.GetByID(ctx, userID) if err != nil { - return nil, fmt.Errorf("invalid token: %w", err) + return nil, errors.New("user not found") } - userResponse, err := s.userProcessor.GetUserByID(ctx, claims.UserID) + if !user.IsActive { + return nil, errors.New("user account is inactive") + } + + token, err := s.generateToken(user) if err != nil { - return nil, fmt.Errorf("user not found: %w", err) + return nil, err } - if !userResponse.IsActive { - return nil, fmt.Errorf("user account is deactivated") - } + expiresAt := time.Now().Add(24 * time.Hour) - roles, _ := s.userProcessor.GetUserRoles(ctx, userResponse.ID) - permCodes, _ := s.userProcessor.GetUserPermissionCodes(ctx, userResponse.ID) - newToken, expiresAt, err := s.generateToken(userResponse, roles, permCodes) - if err != nil { - return nil, fmt.Errorf("failed to generate token: %w", err) - } - - // Departments are now preloaded, so they're already in userResponse return &contract.LoginResponse{ - Token: newToken, - ExpiresAt: expiresAt, - User: *userResponse, - Roles: roles, - Permissions: permCodes, - Departments: userResponse.DepartmentResponse, + Token: token, + ExpiresAt: expiresAt, + User: transformer.EntityToContract(user), }, nil } -func (s *AuthServiceImpl) Logout(ctx context.Context, tokenString string) error { - _, err := s.parseToken(tokenString) +func (s *AuthServiceImpl) GetProfile(ctx context.Context, userID uuid.UUID) (*contract.UserResponse, error) { + user, err := s.userRepo.GetByID(ctx, userID) if err != nil { - return fmt.Errorf("invalid token: %w", err) + return nil, err } - return nil + + return transformer.EntityToContract(user), nil } -func (s *AuthServiceImpl) generateToken(user *contract.UserResponse, roles []contract.RoleResponse, permissionCodes []string) (string, time.Time, error) { - expiresAt := time.Now().Add(s.tokenTTL) - - roleCodes := make([]string, 0, len(roles)) - for _, r := range roles { - roleCodes = append(roleCodes, r.Code) - } - - claims := &Claims{ - UserID: user.ID, - Email: user.Email, - Roles: roleCodes, - Permissions: permissionCodes, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(expiresAt), - IssuedAt: jwt.NewNumericDate(time.Now()), - NotBefore: jwt.NewNumericDate(time.Now()), - Issuer: "eslogad-be", - Subject: user.ID.String(), - }, +func (s *AuthServiceImpl) generateToken(user *entities.User) (string, error) { + claims := jwt.RegisteredClaims{ + Subject: user.ID.String(), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString([]byte(s.jwtSecret)) - if err != nil { - return "", time.Time{}, err - } - - return tokenString, expiresAt, nil + return token.SignedString([]byte(s.jwtSecret)) } -func (s *AuthServiceImpl) parseToken(tokenString string) (*Claims, error) { - token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } +func (s *AuthServiceImpl) ValidateToken(tokenString string) (*jwt.RegisteredClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) { return []byte(s.jwtSecret), nil }) @@ -178,17 +118,13 @@ func (s *AuthServiceImpl) parseToken(tokenString string) (*Claims, error) { return nil, err } - if claims, ok := token.Claims.(*Claims); ok && token.Valid { + if claims, ok := token.Claims.(*jwt.RegisteredClaims); ok && token.Valid { return claims, nil } return nil, errors.New("invalid token") } -func (s *AuthServiceImpl) ExtractAccess(tokenString string) (roles []string, permissions []string, err error) { - claims, err := s.parseToken(tokenString) - if err != nil { - return nil, nil, err - } - return claims.Roles, claims.Permissions, nil +func (s *AuthServiceImpl) GetUserByID(ctx context.Context, userID uuid.UUID) (*entities.User, error) { + return s.userRepo.GetByID(ctx, userID) } diff --git a/internal/service/disposition_route_service.go b/internal/service/disposition_route_service.go deleted file mode 100644 index b70a38f..0000000 --- a/internal/service/disposition_route_service.go +++ /dev/null @@ -1,223 +0,0 @@ -package service - -import ( - "context" - "sort" - - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" - "eslogad-be/internal/transformer" - - "github.com/google/uuid" -) - -type DispositionRouteServiceImpl struct { - repo *repository.DispositionRouteRepository -} - -func NewDispositionRouteService(repo *repository.DispositionRouteRepository) *DispositionRouteServiceImpl { - return &DispositionRouteServiceImpl{repo: repo} -} - -// 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 { - isActive = *req.IsActive - } - - var allowedActions entities.JSONB - if req.AllowedActions != nil { - allowedActions = entities.JSONB(*req.AllowedActions) - } - - // Perform bulk upsert - created, updated, err := s.repo.BulkUpsert(ctx, req.FromDepartmentID, req.ToDepartmentIDs, isActive, allowedActions) - if err != nil { - return nil, err - } - - // 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) - if err != nil { - return nil, err - } - if req.IsActive != nil { - entity.IsActive = *req.IsActive - } - if req.AllowedActions != nil { - entity.AllowedActions = entities.JSONB(*req.AllowedActions) - } - if err := s.repo.Update(ctx, entity); err != nil { - return nil, err - } - resp := transformer.DispositionRoutesToContract([]entities.DispositionRoute{*entity})[0] - return &resp, nil -} -func (s *DispositionRouteServiceImpl) Get(ctx context.Context, id uuid.UUID) (*contract.DispositionRouteResponse, error) { - entity, err := s.repo.Get(ctx, id) - if err != nil { - return nil, err - } - resp := transformer.DispositionRoutesToContract([]entities.DispositionRoute{*entity})[0] - return &resp, nil -} -func (s *DispositionRouteServiceImpl) ListByFromDept(ctx context.Context, from uuid.UUID) (*contract.ListDispositionRoutesResponse, error) { - list, err := s.repo.ListByFromDept(ctx, from) - if err != nil { - return nil, err - } - return &contract.ListDispositionRoutesResponse{Routes: transformer.DispositionRoutesToContract(list)}, nil -} -func (s *DispositionRouteServiceImpl) SetActive(ctx context.Context, id uuid.UUID, active bool) error { - return s.repo.SetActive(ctx, id, active) -} - -// 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/dukcapil_service.go b/internal/service/dukcapil_service.go new file mode 100644 index 0000000..b273883 --- /dev/null +++ b/internal/service/dukcapil_service.go @@ -0,0 +1,70 @@ +package service + +import ( + "context" + "encoding/json" + "strconv" + + "go-backend-template/internal/client" + "go-backend-template/internal/contract" +) + +type DukcapilService interface { + FaceMatch(ctx context.Context, req *contract.FaceMatchRequest) (*contract.FaceMatchResponse, error) +} + +type DukcapilServiceImpl struct { + client *client.DukcapilClient +} + +func NewDukcapilService(c *client.DukcapilClient) *DukcapilServiceImpl { + return &DukcapilServiceImpl{client: c} +} + +func (s *DukcapilServiceImpl) FaceMatch(ctx context.Context, req *contract.FaceMatchRequest) (*contract.FaceMatchResponse, error) { + upstream, err := s.client.FaceMatch(ctx, req) + if err != nil { + return nil, err + } + + matches := parseFaceMatches(upstream.Response) + + return &contract.FaceMatchResponse{ + TID: upstream.TID, + ErrorCode: upstream.ErrorCode, + Error: upstream.Error, + RequestType: upstream.RequestType, + Threshold: upstream.FaceThreshold, + MaxResults: upstream.MaxResults, + Matches: matches, + Raw: upstream, + }, nil +} + +// parseFaceMatches decodes the nested string field +// `{"face":{"FACE_T5":{"":, ...}}}` into a slice of results. +func parseFaceMatches(raw string) []contract.FaceMatchResult { + if raw == "" { + return nil + } + var envelope struct { + Face map[string]map[string]json.Number `json:"face"` + } + if err := json.Unmarshal([]byte(raw), &envelope); err != nil { + return nil + } + results := make([]contract.FaceMatchResult, 0) + for _, group := range envelope.Face { + for nik, scoreNum := range group { + score, err := strconv.ParseFloat(scoreNum.String(), 64) + if err != nil { + continue + } + results = append(results, contract.FaceMatchResult{NIK: nik, Score: score}) + } + } + return results +} + +// Compile-time assertion. +var _ DukcapilService = (*DukcapilServiceImpl)(nil) diff --git a/internal/service/file_service.go b/internal/service/file_service.go deleted file mode 100644 index 1e6eb9a..0000000 --- a/internal/service/file_service.go +++ /dev/null @@ -1,109 +0,0 @@ -package service - -import ( - "context" - "path/filepath" - "strings" - "time" - - "eslogad-be/internal/contract" - - "github.com/google/uuid" -) - -type FileStorage interface { - Upload(ctx context.Context, bucket, key string, content []byte, contentType string) (string, error) - EnsureBucket(ctx context.Context, bucket string) error -} - -type FileServiceImpl struct { - storage FileStorage - userProcessor UserProcessor - profileBucket string - docBucket string - finalBucket string -} - -func NewFileService(storage FileStorage, userProcessor UserProcessor, profileBucket, docBucket string, finalBucket string) *FileServiceImpl { - return &FileServiceImpl{storage: storage, userProcessor: userProcessor, profileBucket: profileBucket, docBucket: docBucket, finalBucket: finalBucket} -} - -func (s *FileServiceImpl) UploadProfileAvatar(ctx context.Context, userID uuid.UUID, filename string, content []byte, contentType string) (string, error) { - if err := s.storage.EnsureBucket(ctx, s.profileBucket); err != nil { - return "", err - } - ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) - if mimeExt := mimeExtFromContentType(contentType); mimeExt != "" { - ext = mimeExt - } - key := buildObjectKey("profile", userID, ext) - url, err := s.storage.Upload(ctx, s.profileBucket, key, content, contentType) - if err != nil { - return "", err - } - - _, _ = s.userProcessor.UpdateUserProfile(ctx, userID, &contract.UpdateUserProfileRequest{AvatarURL: &url}) - return url, nil -} - -func (s *FileServiceImpl) UploadDocument(ctx context.Context, userID uuid.UUID, filename string, content []byte, contentType string) (string, string, error) { - if err := s.storage.EnsureBucket(ctx, s.docBucket); err != nil { - return "", "", err - } - ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) - if mimeExt := mimeExtFromContentType(contentType); mimeExt != "" { - ext = mimeExt - } - key := buildObjectKey("documents", userID, ext) - url, err := s.storage.Upload(ctx, s.docBucket, key, content, contentType) - if err != nil { - return "", "", err - } - return url, key, nil -} - -func (s *FileServiceImpl) UploadDocumentFinal(ctx context.Context, userID uuid.UUID, filename string, content []byte, contentType string) (string, string, error) { - if err := s.storage.EnsureBucket(ctx, s.docBucket); err != nil { - return "", "", err - } - ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) - if mimeExt := mimeExtFromContentType(contentType); mimeExt != "" { - ext = mimeExt - } - key := buildObjectKey("finals", userID, ext) - url, err := s.storage.Upload(ctx, s.docBucket, key, content, contentType) - if err != nil { - return "", "", err - } - return url, key, nil -} - -func buildObjectKey(prefix string, userID uuid.UUID, ext string) string { - now := time.Now().UTC() - parts := []string{ - prefix, - userID.String(), - now.Format("2006/01/02"), - uuid.New().String(), - } - key := strings.Join(parts, "/") - if ext != "" { - key += "." + ext - } - return key -} - -func mimeExtFromContentType(ct string) string { - switch strings.ToLower(ct) { - case "image/jpeg", "image/jpg": - return "jpg" - case "image/png": - return "png" - case "image/webp": - return "webp" - case "application/pdf": - return "pdf" - default: - return "" - } -} diff --git a/internal/service/letter_outgoing_service.go b/internal/service/letter_outgoing_service.go deleted file mode 100644 index 4d6b2a0..0000000 --- a/internal/service/letter_outgoing_service.go +++ /dev/null @@ -1,2132 +0,0 @@ -package service - -import ( - "context" - "fmt" - "log" - "sort" - "time" - - "eslogad-be/internal/appcontext" - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" - "eslogad-be/internal/processor" - "eslogad-be/internal/repository" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type LetterOutgoingService interface { - CreateOutgoingLetter(ctx context.Context, req *contract.CreateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) - GetOutgoingLetterByID(ctx context.Context, id uuid.UUID) (*contract.OutgoingLetterResponse, error) - ListOutgoingLetters(ctx context.Context, req *contract.ListOutgoingLettersRequest) (*contract.ListOutgoingLettersResponse, error) - SearchOutgoingLetters(ctx context.Context, req *contract.SearchOutgoingLettersRequest) (*contract.SearchOutgoingLettersResponse, error) - UpdateOutgoingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) - DeleteOutgoingLetter(ctx context.Context, id uuid.UUID) error - BulkDeleteOutgoingLetters(ctx context.Context, ids []uuid.UUID) error - - SubmitForApproval(ctx context.Context, letterID uuid.UUID) error - ApproveOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.ApproveLetterRequest) error - RejectOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.RejectLetterRequest) error - SendOutgoingLetter(ctx context.Context, letterID uuid.UUID) error - ArchiveOutgoingLetter(ctx context.Context, letterID uuid.UUID) error - - AddRecipients(ctx context.Context, letterID uuid.UUID, req *contract.AddRecipientsRequest) error - UpdateRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID, req *contract.UpdateRecipientRequest) error - RemoveRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID) error - - AddAttachments(ctx context.Context, letterID uuid.UUID, req *contract.AddAttachmentsRequest) error - RemoveAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error - - AddFinalAttachments(ctx context.Context, letterID uuid.UUID, req *contract.AddAttachmentsRequest) error - RemoveFinalAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error - - CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateDiscussionRequest) (*contract.DiscussionResponse, error) - UpdateDiscussion(ctx context.Context, discussionID uuid.UUID, req *contract.UpdateDiscussionRequest) error - DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error - - GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error) - - // GetLetterApprovals returns all approvals and their status for a letter - GetLetterApprovals(ctx context.Context, letterID uuid.UUID) (*contract.GetLetterApprovalsResponse, error) - - // GetApprovalDiscussions returns both approvals and discussions for an outgoing letter - GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error) - - // GetApprovalTimeline returns a chronological timeline of all events for a letter - GetApprovalTimeline(ctx context.Context, letterID uuid.UUID) (*contract.ApprovalTimelineResponse, error) -} - -type LetterOutgoingServiceImpl struct { - processor processor.LetterOutgoingProcessor - txManager *repository.TxManager - validationProcessor processor.LetterValidationProcessor - creationProcessor processor.LetterCreationProcessor - approvalProcessor processor.LetterApprovalProcessor - attachmentProcessor processor.LetterAttachmentProcessor - recipientProcessor processor.LetterOutgoingRecipientProcessor - notificationProcessor processor.NotificationProcessor - activityProcessor processor.LetterActivityProcessor -} - -func NewLetterOutgoingService( - processor processor.LetterOutgoingProcessor, - txManager *repository.TxManager, - validationProcessor processor.LetterValidationProcessor, - creationProcessor processor.LetterCreationProcessor, - approvalProcessor processor.LetterApprovalProcessor, - attachmentProcessor processor.LetterAttachmentProcessor, - recipientProcessor processor.LetterOutgoingRecipientProcessor, - notificationProcessor processor.NotificationProcessor, - activityProcessor processor.LetterActivityProcessor, -) *LetterOutgoingServiceImpl { - return &LetterOutgoingServiceImpl{ - processor: processor, - txManager: txManager, - validationProcessor: validationProcessor, - creationProcessor: creationProcessor, - approvalProcessor: approvalProcessor, - attachmentProcessor: attachmentProcessor, - recipientProcessor: recipientProcessor, - notificationProcessor: notificationProcessor, - activityProcessor: activityProcessor, - } -} - -func (s *LetterOutgoingServiceImpl) CreateOutgoingLetter(ctx context.Context, req *contract.CreateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) { - departmentID := getDepartmentIDFromContext(ctx) - userID := getUserIDFromContext(ctx) - - existingOutgoing, err := s.processor.GetOutgoingLetterByReferenceNumber(ctx, req.ReferenceNumber) - - if err == nil && existingOutgoing != nil { - return nil, fmt.Errorf("surat dengan nomor %s sudah ada", *req.ReferenceNumber) - } - - // Create letter entity - letter := &entities.LetterOutgoing{ - Subject: req.Subject, - Description: req.Description, - PriorityID: req.PriorityID, - ReceiverInstitutionID: req.ReceiverInstitutionID, - ReceiverName: req.ReceiverName, - IssueDate: req.IssueDate, - CreatedBy: userID, - ApprovalFlowID: req.ApprovalFlowID, - } - - if req.ReferenceNumber != nil { - letter.ReferenceNumber = req.ReferenceNumber - } - - // Prepare attachments - var attachments []entities.LetterOutgoingAttachment - if len(req.Attachments) > 0 { - attachments = make([]entities.LetterOutgoingAttachment, len(req.Attachments)) - for i, a := range req.Attachments { - attachments[i] = entities.LetterOutgoingAttachment{ - FileURL: a.FileURL, - FileName: a.FileName, - FileType: a.FileType, - UploadedBy: &userID, - } - } - } - - // Execute creation with transaction in service layer - err = s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - // Step 1: Validate letter - if err := s.validationProcessor.ValidateCreateOutgoingLetter(txCtx, letter); err != nil { - return err - } - - // Step 2: Prepare letter for creation (assign approval flow, set initial status) - if err := s.creationProcessor.PrepareLetterForCreation(txCtx, letter, departmentID); err != nil { - return err - } - - // Step 3: Generate letter number - if err := s.creationProcessor.GenerateLetterNumber(txCtx, letter); err != nil { - return err - } - - // Step 4: Create the letter - if err := s.creationProcessor.CreateLetter(txCtx, letter); err != nil { - return err - } - - // Step 5: Create approval steps if needed - if err := s.approvalProcessor.CreateApprovalSteps(txCtx, letter); err != nil { - return err - } - - // Step 6: Create initial recipients (approval workflow users + department members) - if err := s.recipientProcessor.CreateInitialRecipients(txCtx, letter, departmentID); err != nil { - return err - } - - // Step 7: Create attachments - if err := s.attachmentProcessor.CreateAttachments(txCtx, letter.ID, attachments); err != nil { - return err - } - - // Step 8: Log the activity - return s.activityProcessor.LogActivity(txCtx, letter.ID, entities.LetterOutgoingActionCreated, userID, nil) - }) - - if err != nil { - return nil, err - } - - // Get the created letter with all relationships - result, err := s.processor.GetOutgoingLetterByID(ctx, letter.ID) - if err != nil { - return nil, err - } - - // Send notifications if letter needs approval - log.Printf("[DEBUG] createOutgoingLetter Finsig") - log.Printf("[DEBUG] NotificationProcessor is nil: %v", s.notificationProcessor == nil) - if s.notificationProcessor != nil && len(result.Approvals) > 0 { - log.Printf("[DEBUG] sendFirstStepApprovalNotifications start") - go s.sendStepApprovalNotifications(context.Background(), result.ID, result.Subject, 1) - } - - return transformLetterToResponse(result), nil -} - -func (s *LetterOutgoingServiceImpl) GetOutgoingLetterByID(ctx context.Context, id uuid.UUID) (*contract.OutgoingLetterResponse, error) { - letter, err := s.processor.GetOutgoingLetterByID(ctx, id) - if err != nil { - return nil, err - } - - return transformLetterToResponse(letter), nil -} - -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 - } - - filter := repository.ListOutgoingLettersFilter{ - CreatedBy: req.CreatedBy, - DepartmentID: req.DepartmentID, - ReceiverInstitutionID: req.ReceiverInstitutionID, - PriorityID: req.PriorityID, - PriorityIDs: req.PriorityIDs, - UserID: &userID, - IsRead: req.IsRead, - } - - if departmentID != uuid.Nil { - filter.DepartmentID = &departmentID - } - - if len(req.PriorityIDs) > 0 { - filter.PriorityIDs = req.PriorityIDs - } - - if req.Status != "" { - filter.Status = &req.Status - } - if req.Query != "" { - filter.Query = &req.Query - } - if req.SortBy != "" { - filter.SortBy = &req.SortBy - } - if req.SortOrder != "" { - filter.SortOrder = &req.SortOrder - } - - if req.FromDate != "" { - if date, err := time.Parse("2006-01-02", req.FromDate); err == nil { - filter.FromDate = &date - } - } - - if req.ToDate != "" { - if date, err := time.Parse("2006-01-02", req.ToDate); err == nil { - endOfDay := date.Add(23*time.Hour + 59*time.Minute + 59*time.Second) - filter.ToDate = &endOfDay - } - } - - archived := true - filter.IsArchived = &archived - if filter.IsArchived != nil { - filter.IsArchived = req.IsArchived - } - - if filter.IsRead != nil { - filter.IsRead = req.IsRead - } - - fmt.Printf("[DEBUG] filter: %v\n", filter) - - // 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 - } - } - - isRead := false - recipientByUser := make(map[uuid.UUID]*entities.LetterOutgoingRecipient) - recipientByUser, err = s.processor.GetBatchOutgoingRecipientsByUser(ctx, letterIDs, userID) - if err != nil { - // Handle error - return nil, err - } - - // Ambil isRead dari recipientByUser berdasarkan letter.ID - if recipient, exists := recipientByUser[letter.ID]; exists && recipient != nil { - isRead = recipient.ReadAt != nil - } - - response := transformLetterToResponse(&letter) - response.IsRead = isRead - items[i] = response - } - - return &contract.ListOutgoingLettersResponse{ - Items: items, - Total: total, - }, nil -} - -func (s *LetterOutgoingServiceImpl) SearchOutgoingLetters(ctx context.Context, req *contract.SearchOutgoingLettersRequest) (*contract.SearchOutgoingLettersResponse, error) { - userID := getUserIDFromContext(ctx) - departmentID := getDepartmentIDFromContext(ctx) - - // Build search filters - filters := buildOutgoingSearchFilters(req, userID, departmentID) - - // Execute search with pagination - letters, total, err := s.processor.SearchOutgoingLetters(ctx, filters, req.Page, req.Limit, req.SortBy, req.SortOrder) - if err != nil { - return nil, err - } - - // Collect IDs for batch loading - letterIDs := make([]uuid.UUID, len(letters)) - priorityIDMap := make(map[uuid.UUID]bool) - institutionIDMap := make(map[uuid.UUID]bool) - - for i, letter := range letters { - letterIDs[i] = letter.ID - if letter.PriorityID != nil { - priorityIDMap[*letter.PriorityID] = true - } - if letter.ReceiverInstitutionID != nil { - institutionIDMap[*letter.ReceiverInstitutionID] = true - } - } - - // Convert maps to slices - priorityIDSlice := make([]uuid.UUID, 0, len(priorityIDMap)) - for id := range priorityIDMap { - priorityIDSlice = append(priorityIDSlice, id) - } - - institutionIDSlice := make([]uuid.UUID, 0, len(institutionIDMap)) - for id := range institutionIDMap { - institutionIDSlice = append(institutionIDSlice, id) - } - - // Parallel batch loading - type batchLoadResult 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 - } - - var result batchLoadResult - 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) - } - - return &contract.SearchOutgoingLettersResponse{ - Letters: items, - TotalCount: total, - Page: req.Page, - Limit: req.Limit, - }, nil -} - -func buildOutgoingSearchFilters(req *contract.SearchOutgoingLettersRequest, userID, departmentID uuid.UUID) map[string]interface{} { - filters := make(map[string]interface{}) - - if req.Query != "" { - filters["query"] = req.Query - } - if req.LetterNumber != "" { - filters["letter_number"] = req.LetterNumber - } - if req.Subject != "" { - filters["subject"] = req.Subject - } - if req.Status != "" { - filters["status"] = req.Status - } - if req.PriorityID != nil { - filters["priority_id"] = *req.PriorityID - } - if req.InstitutionID != nil { - filters["receiver_institution_id"] = *req.InstitutionID - } - if req.CreatedBy != nil { - filters["created_by"] = *req.CreatedBy - } - if req.DateFrom != nil { - filters["date_from"] = *req.DateFrom - } - if req.DateTo != nil { - filters["date_to"] = *req.DateTo - } - - // Add user/department context filters - filters["user_context"] = map[string]interface{}{ - "user_id": userID, - "department_id": departmentID, - } - - return filters -} - -func (s *LetterOutgoingServiceImpl) UpdateOutgoingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) { - userID := getUserIDFromContext(ctx) - - letter, err := s.processor.GetOutgoingLetterByID(ctx, id) - if err != nil { - return nil, err - } - - if letter.Status != entities.LetterOutgoingStatusDraft { - return nil, gorm.ErrInvalidData - } - - if req.Subject != nil { - letter.Subject = *req.Subject - } - if req.Description != nil { - letter.Description = req.Description - } - if req.PriorityID != nil { - letter.PriorityID = req.PriorityID - } - if req.ReceiverInstitutionID != nil { - letter.ReceiverInstitutionID = req.ReceiverInstitutionID - } - if req.IssueDate != nil { - letter.IssueDate = *req.IssueDate - } - if req.ReferenceNumber != nil { - letter.ReferenceNumber = req.ReferenceNumber - } - if req.ReceiverName != nil { - letter.ReceiverName = req.ReceiverName - } - - err = s.processor.UpdateOutgoingLetter(ctx, letter, userID) - if err != nil { - return nil, err - } - - result, err := s.processor.GetOutgoingLetterByID(ctx, id) - if err != nil { - return nil, err - } - - return transformLetterToResponse(result), nil -} - -func (s *LetterOutgoingServiceImpl) DeleteOutgoingLetter(ctx context.Context, id uuid.UUID) error { - userID := getUserIDFromContext(ctx) - - //letter, err := s.processor.GetOutgoingLetterByID(ctx, id) - //if err != nil { - // return err - //} - - //if letter.Status != entities.LetterOutgoingStatusDraft { - // return gorm.ErrInvalidData - //} - - return s.processor.DeleteOutgoingLetter(ctx, id, userID) -} - -func (s *LetterOutgoingServiceImpl) BulkDeleteOutgoingLetters(ctx context.Context, ids []uuid.UUID) error { - if len(ids) == 0 { - return nil - } - - userID := getUserIDFromContext(ctx) - - // Validasi semua letters sebelum delete - //for _, id := range ids { - // letter, err := s.processor.GetOutgoingLetterByID(ctx, id) - // if err != nil { - // return err - // } - // - // if letter.Status != entities.LetterOutgoingStatusDraft { - // return gorm.ErrInvalidData - // } - //} - - return s.processor.BulkDeleteOutgoingLetters(ctx, ids, userID) -} - -func (s *LetterOutgoingServiceImpl) SubmitForApproval(ctx context.Context, letterID uuid.UUID) error { - userID := getUserIDFromContext(ctx) - - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return err - } - - if letter.Status != entities.LetterOutgoingStatusDraft { - return gorm.ErrInvalidData - } - - if letter.ApprovalFlowID == nil { - return gorm.ErrInvalidData - } - - return s.processor.ProcessApprovalSubmission(ctx, letterID, *letter.ApprovalFlowID, userID) -} - -func (s *LetterOutgoingServiceImpl) ApproveOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.ApproveLetterRequest) error { - userID := getUserIDFromContext(ctx) - - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return err - } - - if letter.Status != entities.LetterOutgoingStatusPendingApproval { - return gorm.ErrInvalidData - } - - // Get approvals for the current revision only - approvals, err := s.processor.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) - if err != nil { - return err - } - - // Find user's pending approval - var currentApproval *entities.LetterOutgoingApproval - for i := range approvals { - if approvals[i].Status == entities.ApprovalStatusPending && - approvals[i].ApproverID != nil && - *approvals[i].ApproverID == userID { - currentApproval = &approvals[i] - break - } - } - - if currentApproval == nil { - return gorm.ErrInvalidData - } - - currentApproval.Remarks = req.Remarks - - err = s.processor.ProcessApproval(ctx, letterID, currentApproval, userID) - if err != nil { - return err - } - - // Send notifications after successful approval - if s.notificationProcessor != nil { - // Get next parallel group to determine notification message - nextParallelGroup := s.getNextParallelGroup(approvals, currentApproval.ParallelGroup) - - if nextParallelGroup > 0 { - // Notify creator about group completion AND next group approvers - creatorMessage := fmt.Sprintf("Surat keluar '%s' telah disetujui pada grup %d, menunggu persetujuan grup berikutnya", letter.Subject, currentApproval.ParallelGroup) - go s.sendApprovalNotificationToCreator(context.Background(), letterID, letter.CreatedBy, "Surat Keluar Disetujui Grup "+fmt.Sprintf("%d", currentApproval.ParallelGroup), creatorMessage) - - // Notify next parallel group approvers - go s.sendParallelGroupApprovalNotifications(context.Background(), letterID, letter.Subject, nextParallelGroup) - } else { - // All groups completed - creatorMessage := fmt.Sprintf("Surat keluar '%s' telah selesai disetujui", letter.Subject) - go s.sendApprovalNotificationToCreator(context.Background(), letterID, letter.CreatedBy, "Surat Keluar Disetujui", creatorMessage) - } - } - - return nil -} - -func (s *LetterOutgoingServiceImpl) RejectOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.RejectLetterRequest) error { - userID := getUserIDFromContext(ctx) - - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return err - } - - if letter.Status != entities.LetterOutgoingStatusPendingApproval { - return gorm.ErrInvalidData - } - - // Get approvals for the current revision only - approvals, err := s.processor.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) - if err != nil { - return err - } - - // Find user's pending approval - var currentApproval *entities.LetterOutgoingApproval - for i := range approvals { - if approvals[i].Status == entities.ApprovalStatusPending && - approvals[i].ApproverID != nil && - *approvals[i].ApproverID == userID { - currentApproval = &approvals[i] - break - } - } - - if currentApproval == nil { - return gorm.ErrInvalidData - } - - currentApproval.Remarks = &req.Reason - - err = s.processor.ProcessRejection(ctx, letterID, currentApproval, userID) - if err != nil { - return err - } - - // Send notification to letter creator (rejection always notifies creator) - if s.notificationProcessor != nil { - message := fmt.Sprintf("Surat keluar '%s' ditolak pada grup %d dengan alasan: %s", letter.Subject, currentApproval.ParallelGroup, req.Reason) - go s.sendApprovalNotificationToCreator(context.Background(), letterID, letter.CreatedBy, "Surat Keluar Ditolak", message) - } - - return nil -} - -func (s *LetterOutgoingServiceImpl) ReviseOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.ReviseLetterRequest) error { - userID := getUserIDFromContext(ctx) - - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return err - } - - // Can only revise rejected letters - if letter.Status != entities.LetterOutgoingStatusRejected { - return gorm.ErrInvalidData - } - - attachment := entities.LetterOutgoingAttachment{ - LetterID: letterID, - FileURL: req.FileURL, - FileName: req.FileName, - FileType: req.FileType, - UploadedBy: &userID, - } - - err = s.processor.ProcessRevision(ctx, letterID, attachment, userID) - if err != nil { - return err - } - - return nil -} - -func (s *LetterOutgoingServiceImpl) SendOutgoingLetter(ctx context.Context, letterID uuid.UUID) error { - userID := getUserIDFromContext(ctx) - - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return err - } - - if letter.Status != entities.LetterOutgoingStatusApproved { - return gorm.ErrInvalidData - } - - fromStatus := string(entities.LetterOutgoingStatusApproved) - toStatus := string(entities.LetterOutgoingStatusSent) - return s.processor.UpdateLetterStatus(ctx, letterID, entities.LetterOutgoingStatusSent, userID, &fromStatus, &toStatus) -} - -func (s *LetterOutgoingServiceImpl) ArchiveOutgoingLetter(ctx context.Context, letterID uuid.UUID) error { - userID := getUserIDFromContext(ctx) - - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return err - } - - // Can only archive sent letters - if letter.Status != entities.LetterOutgoingStatusSent { - return gorm.ErrInvalidData - } - - // Use the new archive method instead of changing status - return s.processor.ArchiveOutgoingLetter(ctx, letterID, userID) -} - -func (s *LetterOutgoingServiceImpl) AddRecipients(ctx context.Context, letterID uuid.UUID, req *contract.AddRecipientsRequest) error { - userID := getUserIDFromContext(ctx) - - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return err - } - - if letter.Status != entities.LetterOutgoingStatusDraft { - return gorm.ErrInvalidData - } - - recipients := make([]entities.LetterOutgoingRecipient, len(req.Recipients)) - for i, r := range req.Recipients { - recipients[i] = entities.LetterOutgoingRecipient{ - LetterID: letterID, - UserID: r.UserID, - DepartmentID: r.DepartmentID, - IsPrimary: r.IsPrimary, - Status: r.Status, - Flag: r.Flag, - IsArchived: r.IsArchived, - } - } - - return s.processor.AddRecipients(ctx, letterID, recipients, userID) -} - -func (s *LetterOutgoingServiceImpl) UpdateRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID, req *contract.UpdateRecipientRequest) error { - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return err - } - - if letter.Status != entities.LetterOutgoingStatusDraft { - return gorm.ErrInvalidData - } - - recipient := &entities.LetterOutgoingRecipient{ - ID: recipientID, - IsPrimary: req.IsPrimary, - } - - if req.UserID != nil { - recipient.UserID = req.UserID - } - if req.DepartmentID != nil { - recipient.DepartmentID = req.DepartmentID - } - if req.Status != nil { - recipient.Status = *req.Status - } - if req.Flag != nil { - recipient.Flag = req.Flag - } - if req.IsArchived != nil { - recipient.IsArchived = *req.IsArchived - } - - return s.processor.UpdateRecipient(ctx, recipient) -} - -func (s *LetterOutgoingServiceImpl) RemoveRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID) error { - userID := getUserIDFromContext(ctx) - - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return err - } - - if letter.Status != entities.LetterOutgoingStatusDraft { - return gorm.ErrInvalidData - } - - return s.processor.RemoveRecipient(ctx, letterID, recipientID, userID) -} - -func (s *LetterOutgoingServiceImpl) AddAttachments(ctx context.Context, letterID uuid.UUID, req *contract.AddAttachmentsRequest) error { - userID := getUserIDFromContext(ctx) - - _, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return err - } - - //if letter.Status != entities.LetterOutgoingStatusDraft { - // return gorm.ErrInvalidData - //} - - attachments := make([]entities.LetterOutgoingAttachment, len(req.Attachments)) - for i, a := range req.Attachments { - attachments[i] = entities.LetterOutgoingAttachment{ - LetterID: letterID, - FileURL: a.FileURL, - FileName: a.FileName, - FileType: a.FileType, - } - } - - return s.processor.AddAttachments(ctx, letterID, attachments, userID) -} - -func (s *LetterOutgoingServiceImpl) RemoveAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error { - userID := getUserIDFromContext(ctx) - - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return err - } - - if letter.Status != entities.LetterOutgoingStatusDraft { - return gorm.ErrInvalidData - } - - return s.processor.RemoveAttachment(ctx, letterID, attachmentID, userID) -} - -func (s *LetterOutgoingServiceImpl) AddFinalAttachments(ctx context.Context, letterID uuid.UUID, req *contract.AddAttachmentsRequest) error { - userID := getUserIDFromContext(ctx) - - _, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return err - } - - //if letter.Status != entities.LetterOutgoingStatusDraft { - // return gorm.ErrInvalidData - //} - - attachments := make([]entities.LetterOutgoingFinalAttachment, len(req.Attachments)) - for i, a := range req.Attachments { - attachments[i] = entities.LetterOutgoingFinalAttachment{ - LetterID: letterID, - FileURL: a.FileURL, - FileName: a.FileName, - FileType: a.FileType, - } - } - - return s.processor.AddFinalAttachments(ctx, letterID, attachments, userID) -} - -func (s *LetterOutgoingServiceImpl) RemoveFinalAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error { - userID := getUserIDFromContext(ctx) - - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return err - } - - if letter.Status != entities.LetterOutgoingStatusDraft { - return gorm.ErrInvalidData - } - - return s.processor.RemoveFinalAttachment(ctx, letterID, attachmentID, userID) -} - -func (s *LetterOutgoingServiceImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateDiscussionRequest) (*contract.DiscussionResponse, error) { - userID := getUserIDFromContext(ctx) - - discussion := &entities.LetterOutgoingDiscussion{ - LetterID: letterID, - ParentID: req.ParentID, - UserID: userID, - Message: req.Message, - } - - if req.Mentions != nil { - discussion.Mentions = req.Mentions - } - - var attachments []entities.LetterOutgoingDiscussionAttachment - if len(req.Attachments) > 0 { - attachments = make([]entities.LetterOutgoingDiscussionAttachment, len(req.Attachments)) - for i, a := range req.Attachments { - attachments[i] = entities.LetterOutgoingDiscussionAttachment{ - DiscussionID: discussion.ID, - FileURL: a.FileURL, - FileName: a.FileName, - FileType: a.FileType, - UploadedBy: &userID, - } - } - } - - err := s.processor.CreateDiscussion(ctx, discussion, attachments, userID) - - if err != nil { - return nil, err - } - - result, err := s.processor.GetDiscussionByID(ctx, discussion.ID) - if err != nil { - return nil, err - } - - if s.notificationProcessor != nil && req.Mentions != nil { - go s.sendOutgoingDiscussionMentionNotifications(context.Background(), letterID, userID, req.Mentions, req.Message) - } - - return transformDiscussionToResponse(result), nil -} - -func (s *LetterOutgoingServiceImpl) UpdateDiscussion(ctx context.Context, discussionID uuid.UUID, req *contract.UpdateDiscussionRequest) error { - discussion, err := s.processor.GetDiscussionByID(ctx, discussionID) - if err != nil { - return err - } - - userID := getUserIDFromContext(ctx) - if discussion.UserID != userID { - return gorm.ErrInvalidData - } - - discussion.Message = req.Message - if req.Mentions != nil { - discussion.Mentions = req.Mentions - } - - return s.processor.UpdateDiscussion(ctx, discussion) -} - -func (s *LetterOutgoingServiceImpl) DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error { - discussion, err := s.processor.GetDiscussionByID(ctx, discussionID) - if err != nil { - return err - } - - userID := getUserIDFromContext(ctx) - if discussion.UserID != userID { - return gorm.ErrInvalidData - } - - return s.processor.DeleteDiscussion(ctx, discussionID) -} - -func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error) { - userID := getUserIDFromContext(ctx) - - // Verify letter exists - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return nil, err - } - - // Get all approvals for this letter's current revision - approvals, err := s.processor.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) - if err != nil { - return nil, err - } - - // Simple check: can user approve if they have a pending approval - var canApprove bool - var userApproval *entities.LetterOutgoingApproval - - for i := range approvals { - approval := approvals[i] - - // Check if this approval is pending and belongs to the current user - if approval.Status == entities.ApprovalStatusPending && - approval.ApproverID != nil && - *approval.ApproverID == userID { - canApprove = true - userApproval = &approval - break - } - } - - // Build actions based on eligibility - var actions []contract.ApprovalAction - if canApprove && userApproval != nil { - actions = []contract.ApprovalAction{ - { - Type: "APPROVE", - Href: fmt.Sprintf("/api/v1/letters/outgoing/%s/approve", letterID), - Method: "POST", - }, - { - Type: "REJECT", - Href: fmt.Sprintf("/api/v1/letters/outgoing/%s/reject", letterID), - Method: "POST", - }, - } - } - - // Add REVISE action if letter is rejected and user is the creator - if letter.Status == entities.LetterOutgoingStatusRejected && letter.CreatedBy == userID { - actions = append(actions, contract.ApprovalAction{ - Type: "REVISE", - Href: fmt.Sprintf("/api/v1/letters/outgoing/%s/revise", letterID), - Method: "POST", - }) - } - - // Determine overall decision status - decisionStatus := "PENDING" - - // Check if all required approvals are completed - allCompleted := true - hasRejection := false - for _, approval := range approvals { - // Check required approvals only - if approval.IsRequired { - if approval.Status == entities.ApprovalStatusPending || approval.Status == entities.ApprovalStatusNotStarted { - allCompleted = false - } - if approval.Status == entities.ApprovalStatusRejected { - hasRejection = true - } - } - } - - if hasRejection { - decisionStatus = "REJECTED" - } else if allCompleted { - decisionStatus = "COMPLETED" - } else if letter.Status == entities.LetterOutgoingStatusPendingApproval { - decisionStatus = "PENDING" - } - - // Determine notes visibility - notesVisibility := "READONLY" - if canApprove { - notesVisibility = "FULL" - } - - info := &contract.LetterApprovalInfoResponse{ - IsApproverOnActiveStep: canApprove, // User can approve means they're on the active step - DecisionStatus: decisionStatus, - CanApprove: canApprove, - Actions: actions, - NotesVisibility: notesVisibility, - } - - return info, nil -} - -func (s *LetterOutgoingServiceImpl) GetLetterApprovals(ctx context.Context, letterID uuid.UUID) (*contract.GetLetterApprovalsResponse, error) { - // Get letter details - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return nil, err - } - - // Get all approvals for this letter (all revisions) - approvals, err := s.processor.GetAllApprovalsByLetter(ctx, letterID) - if err != nil { - return nil, err - } - - // Group approvals by revision number from approval itself - revisionMap := make(map[int][]entities.LetterOutgoingApproval) - for _, approval := range approvals { - revisionMap[approval.RevisionNumber] = append(revisionMap[approval.RevisionNumber], approval) - } - - // Get sorted revision numbers - revisionNumbers := make([]int, 0, len(revisionMap)) - for revNum := range revisionMap { - revisionNumbers = append(revisionNumbers, revNum) - } - sort.Sort(sort.Reverse(sort.IntSlice(revisionNumbers))) - - // Process each revision - revisionResponses := make([]contract.OutgoingLetterApprovalRevisionNumberResponse, 0, len(revisionNumbers)) - - totalSteps := 0 - currentStep := 0 - - for _, revNum := range revisionNumbers { - revisionApprovals := revisionMap[revNum] - - // Sort approvals within this revision by step order and parallel group - sort.Slice(revisionApprovals, func(i, j int) bool { - if revisionApprovals[i].StepOrder != revisionApprovals[j].StepOrder { - return revisionApprovals[i].StepOrder < revisionApprovals[j].StepOrder - } - return revisionApprovals[i].ParallelGroup < revisionApprovals[j].ParallelGroup - }) - - // Transform to response format - approvalResponses := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(revisionApprovals)) - - // Only calculate totalSteps and currentStep for the current letter's revision - if revNum == letter.RevisionNumber { - stepOrdersSeen := make(map[int]bool) - - for _, approval := range revisionApprovals { - // Count unique step orders for total steps - if !stepOrdersSeen[approval.StepOrder] { - stepOrdersSeen[approval.StepOrder] = true - totalSteps++ - } - - // Determine current step (lowest step with pending status) - if approval.Status == entities.ApprovalStatusPending && (currentStep == 0 || approval.StepOrder < currentStep) { - currentStep = approval.StepOrder - } - } - - // If no current step found but there are approvals, check if all are completed - if currentStep == 0 && len(revisionApprovals) > 0 { - allCompleted := true - for _, approval := range revisionApprovals { - if approval.IsRequired && approval.Status != entities.ApprovalStatusApproved { - allCompleted = false - break - } - } - if allCompleted { - currentStep = totalSteps // All steps completed - } - } - } - - for _, approval := range revisionApprovals { - approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{ - ID: approval.ID, - LetterID: approval.LetterID, - StepID: approval.StepID, - StepOrder: approval.StepOrder, - ParallelGroup: approval.ParallelGroup, - IsRequired: approval.IsRequired, - ApproverID: approval.ApproverID, - RevisionNumber: approval.RevisionNumber, - Status: string(approval.Status), - Remarks: approval.Remarks, - ActedAt: approval.ActedAt, - CreatedAt: approval.CreatedAt, - } - - // Add step details if available - if approval.Step != nil { - approvalResp.Step = &contract.ApprovalFlowStepResponse{ - ID: approval.Step.ID, - StepOrder: approval.Step.StepOrder, - ParallelGroup: approval.Step.ParallelGroup, - Required: approval.Step.Required, - CreatedAt: approval.Step.CreatedAt, - UpdatedAt: approval.Step.UpdatedAt, - } - - // Add approver role if available - if approval.Step.ApproverRole != nil { - approvalResp.Step.ApproverRole = &contract.RoleResponse{ - ID: approval.Step.ApproverRole.ID, - Name: approval.Step.ApproverRole.Name, - Code: approval.Step.ApproverRole.Code, - } - } - - // Add approver user if available - if approval.Step.ApproverUser != nil { - approvalResp.Step.ApproverUser = &contract.UserResponse{ - ID: approval.Step.ApproverUser.ID, - Name: approval.Step.ApproverUser.Name, - Email: approval.Step.ApproverUser.Email, - } - } - } - - // Add approver details if available - if approval.Approver != nil { - approvalResp.Approver = &contract.UserResponse{ - ID: approval.Approver.ID, - Name: approval.Approver.Name, - Email: approval.Approver.Email, - } - } - - approvalResponses = append(approvalResponses, approvalResp) - } - - // Add revision response - revisionResponses = append(revisionResponses, contract.OutgoingLetterApprovalRevisionNumberResponse{ - RevisionNumber: revNum, - Approvals: approvalResponses, - }) - } - - response := &contract.GetLetterApprovalsResponse{ - LetterID: letter.ID, - LetterNumber: letter.LetterNumber, - LetterStatus: string(letter.Status), - TotalSteps: totalSteps, - CurrentStep: currentStep, - CurrentRevisionNumber: letter.RevisionNumber, - Approvals: revisionResponses, - } - - return response, nil -} -func getUserIDFromContext(ctx context.Context) uuid.UUID { - appCtx := appcontext.FromGinContext(ctx) - if appCtx != nil { - return appCtx.UserID - } - return uuid.New() -} - -func getDepartmentIDFromContext(ctx context.Context) uuid.UUID { - appCtx := appcontext.FromGinContext(ctx) - if appCtx != nil { - return appCtx.DepartmentID - } - return uuid.Nil -} - -func userHasRole(ctx context.Context, roleID uuid.UUID) bool { - return false -} - -func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error) { - // Get the letter with all related data - letter, err := s.processor.GetOutgoingLetterWithDetails(ctx, letterID) - if err != nil { - return nil, err - } - - // Transform approvals - approvals := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(letter.Approvals)) - for _, approval := range letter.Approvals { - approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{ - ID: approval.ID, - LetterID: approval.LetterID, - StepID: approval.StepID, - StepOrder: approval.StepOrder, - ParallelGroup: approval.ParallelGroup, - IsRequired: approval.IsRequired, - ApproverID: approval.ApproverID, - RevisionNumber: approval.RevisionNumber, - Status: string(approval.Status), - Remarks: approval.Remarks, - ActedAt: approval.ActedAt, - CreatedAt: approval.CreatedAt, - } - - // Add step details if available - if approval.Step != nil { - approvalResp.Step = &contract.ApprovalFlowStepResponse{ - ID: approval.Step.ID, - StepOrder: approval.Step.StepOrder, - ParallelGroup: approval.Step.ParallelGroup, - Required: approval.Step.Required, - CreatedAt: approval.Step.CreatedAt, - UpdatedAt: approval.Step.UpdatedAt, - } - - if approval.Step.ApproverRoleID != nil { - approvalResp.Step.ApproverRoleID = approval.Step.ApproverRoleID - } - if approval.Step.ApproverUserID != nil { - approvalResp.Step.ApproverUserID = approval.Step.ApproverUserID - } - - // Add role information if available - if approval.Step.ApproverRole != nil { - approvalResp.Step.ApproverRole = &contract.RoleResponse{ - ID: approval.Step.ApproverRole.ID, - Name: approval.Step.ApproverRole.Name, - Code: approval.Step.ApproverRole.Code, - } - } - - // Add user information if available - if approval.Step.ApproverUser != nil { - approvalResp.Step.ApproverUser = &contract.UserResponse{ - ID: approval.Step.ApproverUser.ID, - Name: approval.Step.ApproverUser.Name, - Email: approval.Step.ApproverUser.Email, - } - } - } - - // Add approver details if available - if approval.Approver != nil { - approvalResp.Approver = &contract.UserResponse{ - ID: approval.Approver.ID, - Name: approval.Approver.Name, - Email: approval.Approver.Email, - } - - // Add profile if available - if approval.Approver.Profile != nil { - approvalResp.Approver.Profile = &contract.UserProfileResponse{ - UserID: approval.Approver.Profile.UserID, - FullName: approval.Approver.Profile.FullName, - DisplayName: approval.Approver.Profile.DisplayName, - Phone: approval.Approver.Profile.Phone, - AvatarURL: approval.Approver.Profile.AvatarURL, - JobTitle: approval.Approver.Profile.JobTitle, - EmployeeNo: approval.Approver.Profile.EmployeeNo, - Bio: approval.Approver.Profile.Bio, - Timezone: approval.Approver.Profile.Timezone, - Locale: approval.Approver.Profile.Locale, - } - } - } - - approvals = append(approvals, approvalResp) - } - - // Transform discussions - discussions := make([]contract.OutgoingLetterDiscussionResponse, 0, len(letter.Discussions)) - for _, discussion := range letter.Discussions { - // Extract mentioned user IDs from mentions - mentionedUserIDs := extractMentionedUserIDs(discussion.Mentions) - - discussionResp := contract.OutgoingLetterDiscussionResponse{ - ID: discussion.ID, - LetterID: discussion.LetterID, - ParentID: discussion.ParentID, - UserID: discussion.UserID, - Message: discussion.Message, - Mentions: discussion.Mentions, - CreatedAt: discussion.CreatedAt, - UpdatedAt: discussion.UpdatedAt, - EditedAt: discussion.EditedAt, - } - - // Add user details if available - if discussion.User != nil { - discussionResp.User = &contract.UserResponse{ - ID: discussion.User.ID, - Name: discussion.User.Name, - Email: discussion.User.Email, - IsActive: discussion.User.IsActive, - CreatedAt: discussion.User.CreatedAt, - UpdatedAt: discussion.User.UpdatedAt, - } - - // Add profile if available - if discussion.User.Profile != nil { - discussionResp.User.Profile = &contract.UserProfileResponse{ - UserID: discussion.User.Profile.UserID, - FullName: discussion.User.Profile.FullName, - DisplayName: discussion.User.Profile.DisplayName, - Phone: discussion.User.Profile.Phone, - AvatarURL: discussion.User.Profile.AvatarURL, - JobTitle: discussion.User.Profile.JobTitle, - EmployeeNo: discussion.User.Profile.EmployeeNo, - Bio: discussion.User.Profile.Bio, - Timezone: discussion.User.Profile.Timezone, - Locale: discussion.User.Profile.Locale, - } - } - } - - // Get mentioned users details - if len(mentionedUserIDs) > 0 { - mentionedUsers, _ := s.processor.GetUsersByIDs(ctx, mentionedUserIDs) - for _, user := range mentionedUsers { - mentionedUserResp := contract.UserResponse{ - ID: user.ID, - Name: user.Name, - Email: user.Email, - IsActive: user.IsActive, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - } - - if user.Profile != nil { - mentionedUserResp.Profile = &contract.UserProfileResponse{ - UserID: user.Profile.UserID, - FullName: user.Profile.FullName, - DisplayName: user.Profile.DisplayName, - Timezone: user.Profile.Timezone, - Locale: user.Profile.Locale, - } - } - - discussionResp.MentionedUsers = append(discussionResp.MentionedUsers, mentionedUserResp) - } - } - - // Add attachments if available - for _, attachment := range discussion.Attachments { - attachmentResp := contract.OutgoingLetterDiscussionAttachmentResponse{ - ID: attachment.ID, - DiscussionID: attachment.DiscussionID, - FileURL: attachment.FileURL, - FileName: attachment.FileName, - FileType: attachment.FileType, - UploadedBy: attachment.UploadedBy, - UploadedAt: attachment.UploadedAt, - } - discussionResp.Attachments = append(discussionResp.Attachments, attachmentResp) - } - - discussions = append(discussions, discussionResp) - } - - return &contract.OutgoingLetterApprovalDiscussionsResponse{ - Approvals: approvals, - Discussions: discussions, - }, nil -} - -// Helper function to extract user IDs from mentions -func extractMentionedUserIDs(mentions map[string]interface{}) []uuid.UUID { - var userIDs []uuid.UUID - - if mentions == nil { - return userIDs - } - - if userIDsInterface, ok := mentions["user_ids"]; ok { - if userIDsList, ok := userIDsInterface.([]interface{}); ok { - for _, id := range userIDsList { - if idStr, ok := id.(string); ok { - if userID, err := uuid.Parse(idStr); err == nil { - userIDs = append(userIDs, userID) - } - } - } - } - } - - return userIDs -} - -func transformLetterToResponse(letter *entities.LetterOutgoing) *contract.OutgoingLetterResponse { - resp := &contract.OutgoingLetterResponse{ - ID: letter.ID, - LetterNumber: letter.LetterNumber, - ReferenceNumber: letter.ReferenceNumber, - Subject: letter.Subject, - Description: letter.Description, - PriorityID: letter.PriorityID, - ReceiverInstitutionID: letter.ReceiverInstitutionID, - ReceiverName: letter.ReceiverName, - IssueDate: letter.IssueDate, - Status: string(letter.Status), - ApprovalFlowID: letter.ApprovalFlowID, - RevisionNumber: letter.RevisionNumber, - CreatedBy: letter.CreatedBy, - CreatedName: letter.Creator.Name, - CreatedAt: letter.CreatedAt, - UpdatedAt: letter.UpdatedAt, - } - - if letter.Priority != nil { - resp.Priority = &contract.PriorityResponse{ - ID: letter.Priority.ID.String(), - Name: letter.Priority.Name, - Level: letter.Priority.Level, - CreatedAt: letter.Priority.CreatedAt, - UpdatedAt: letter.Priority.UpdatedAt, - } - } - - if letter.ReceiverInstitution != nil { - resp.ReceiverInstitution = &contract.InstitutionResponse{ - ID: letter.ReceiverInstitution.ID.String(), - Name: letter.ReceiverInstitution.Name, - Type: string(letter.ReceiverInstitution.Type), - Address: letter.ReceiverInstitution.Address, - ContactPerson: letter.ReceiverInstitution.ContactPerson, - Phone: letter.ReceiverInstitution.Phone, - Email: letter.ReceiverInstitution.Email, - CreatedAt: letter.ReceiverInstitution.CreatedAt, - UpdatedAt: letter.ReceiverInstitution.UpdatedAt, - } - } - - if len(letter.Recipients) > 0 { - resp.Recipients = make([]contract.OutgoingLetterRecipientResponse, len(letter.Recipients)) - for i, recipient := range letter.Recipients { - recipResp := contract.OutgoingLetterRecipientResponse{ - ID: recipient.ID, - LetterID: recipient.LetterID, - UserID: recipient.UserID, - DepartmentID: recipient.DepartmentID, - IsPrimary: recipient.IsPrimary, - Status: recipient.Status, - ReadAt: recipient.ReadAt, - Flag: recipient.Flag, - IsArchived: recipient.IsArchived, - CreatedAt: recipient.CreatedAt, - } - - if recipient.User != nil { - recipResp.User = &contract.UserResponse{ - ID: recipient.User.ID, - Name: recipient.User.Name, - Email: recipient.User.Email, - } - } - - if recipient.Department != nil { - recipResp.Department = &contract.DepartmentResponse{ - ID: recipient.Department.ID, - Name: recipient.Department.Name, - Code: recipient.Department.Code, - } - } - - resp.Recipients[i] = recipResp - } - } - - if len(letter.Attachments) > 0 { - resp.Attachments = make([]contract.OutgoingLetterAttachmentResponse, len(letter.Attachments)) - for i, attachment := range letter.Attachments { - resp.Attachments[i] = contract.OutgoingLetterAttachmentResponse{ - ID: attachment.ID, - FileURL: attachment.FileURL, - FileName: attachment.FileName, - FileType: attachment.FileType, - UploadedAt: attachment.UploadedAt, - } - } - } - - if len(letter.FinalAttachments) > 0 { - resp.FinalAttachments = make([]contract.OutgoingLetterAttachmentResponse, len(letter.FinalAttachments)) - for i, attachment := range letter.FinalAttachments { - resp.FinalAttachments[i] = contract.OutgoingLetterAttachmentResponse{ - ID: attachment.ID, - FileURL: attachment.FileURL, - FileName: attachment.FileName, - FileType: attachment.FileType, - UploadedAt: attachment.UploadedAt, - } - } - } - - if len(letter.Approvals) > 0 { - resp.Approvals = make([]contract.OutgoingLetterApprovalResponse, len(letter.Approvals)) - for i, approval := range letter.Approvals { - approvalResp := contract.OutgoingLetterApprovalResponse{ - ID: approval.ID, - StepOrder: approval.StepOrder, - ParallelGroup: approval.ParallelGroup, - IsRequired: approval.IsRequired, - ApproverID: approval.ApproverID, - Status: string(approval.Status), - Remarks: approval.Remarks, - ActedAt: approval.ActedAt, - CreatedAt: approval.CreatedAt, - } - - resp.Approvals[i] = approvalResp - } - } - - return resp -} - -func transformDiscussionToResponse(discussion *entities.LetterOutgoingDiscussion) *contract.DiscussionResponse { - return &contract.DiscussionResponse{ - ID: discussion.ID, - UserID: discussion.UserID, - Message: discussion.Message, - CreatedAt: discussion.CreatedAt, - UpdatedAt: discussion.UpdatedAt, - } -} - -// GetApprovalTimeline generates a chronological timeline of all events for a letter -func (s *LetterOutgoingServiceImpl) GetApprovalTimeline(ctx context.Context, letterID uuid.UUID) (*contract.ApprovalTimelineResponse, error) { - // Get letter details - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - return nil, err - } - - // Get approvals and discussions - approvalDiscussions, err := s.GetApprovalDiscussions(ctx, letterID) - if err != nil { - return nil, err - } - - // Create timeline events - timeline := make([]contract.TimelineEvent, 0) - - // Add letter creation event - timeline = append(timeline, contract.TimelineEvent{ - ID: letter.ID.String(), - Type: "submission", - Timestamp: letter.CreatedAt, - Actor: nil, // Could add creator info here if needed - Action: "created", - Description: "Letter was created", - Status: "created", - }) - - // Add approval events - for _, approval := range approvalDiscussions.Approvals { - if approval.ActedAt != nil { - eventType := "approval" - action := "approved" - status := "approved" - - if approval.Status == "rejected" { - eventType = "rejection" - action = "rejected" - status = "rejected" - } else if approval.Status == "pending" { - continue // Skip pending approvals as they haven't happened yet - } - - description := fmt.Sprintf("Step %d: %s by %s", - approval.StepOrder, - action, - getApproverName(approval.Approver)) - - timeline = append(timeline, contract.TimelineEvent{ - ID: approval.ID.String(), - Type: eventType, - Timestamp: *approval.ActedAt, - Actor: approval.Approver, - Action: action, - Description: description, - Status: status, - StepOrder: approval.StepOrder, - Message: getLetterStringValue(approval.Remarks), - Data: approval, - }) - } - } - - // Add discussion events - for _, discussion := range approvalDiscussions.Discussions { - timeline = append(timeline, contract.TimelineEvent{ - ID: discussion.ID.String(), - Type: "discussion", - Timestamp: discussion.CreatedAt, - Actor: discussion.User, - Action: "commented", - Description: fmt.Sprintf("%s added a comment", getUserName(discussion.User)), - Message: discussion.Message, - Data: discussion, - }) - } - - // Sort timeline by timestamp - sort.Slice(timeline, func(i, j int) bool { - return timeline[i].Timestamp.Before(timeline[j].Timestamp) - }) - - // Calculate summary statistics - summary := s.calculateTimelineSummary(letter, approvalDiscussions.Approvals, timeline) - - return &contract.ApprovalTimelineResponse{ - LetterID: letter.ID, - LetterNumber: letter.LetterNumber, - Subject: letter.Subject, - Status: string(letter.Status), - CreatedAt: letter.CreatedAt, - Timeline: timeline, - Summary: summary, - }, nil -} - -func (s *LetterOutgoingServiceImpl) calculateTimelineSummary( - letter *entities.LetterOutgoing, - approvals []contract.EnhancedOutgoingLetterApprovalResponse, - timeline []contract.TimelineEvent, -) contract.TimelineSummary { - totalSteps := 0 - completedSteps := 0 - pendingSteps := 0 - currentStep := 0 - - // Count unique step orders - stepMap := make(map[int]string) - for _, approval := range approvals { - if _, exists := stepMap[approval.StepOrder]; !exists { - stepMap[approval.StepOrder] = approval.Status - totalSteps++ - } - - switch approval.Status { - case "approved": - if stepMap[approval.StepOrder] == "approved" { - completedSteps++ - currentStep = approval.StepOrder + 1 - } - case "pending": - pendingSteps++ - if currentStep == 0 { - currentStep = approval.StepOrder - } - } - } - - // Calculate duration - totalDuration := "" - averageStepTime := "" - - if len(timeline) > 0 { - lastEvent := timeline[len(timeline)-1] - duration := lastEvent.Timestamp.Sub(letter.CreatedAt) - totalDuration = formatDuration(duration) - - if completedSteps > 0 { - avgDuration := duration / time.Duration(completedSteps) - averageStepTime = formatDuration(avgDuration) - } - } - - status := "in_progress" - if letter.Status == entities.LetterOutgoingStatusApproved { - status = "completed" - } else if letter.Status == "rejected" { - status = "rejected" - } - - return contract.TimelineSummary{ - TotalSteps: totalSteps, - CompletedSteps: completedSteps, - PendingSteps: pendingSteps, - CurrentStep: currentStep, - TotalDuration: totalDuration, - AverageStepTime: averageStepTime, - Status: status, - } -} - -func getApproverName(user *contract.UserResponse) string { - if user == nil { - return "Unknown" - } - if user.Name != "" { - return user.Name - } - return user.Email -} - -func getUserName(user *contract.UserResponse) string { - if user == nil { - return "Unknown" - } - if user.Name != "" { - return user.Name - } - return user.Email -} - -func getLetterStringValue(s *string) string { - if s == nil { - return "" - } - return *s -} - -func formatDuration(d time.Duration) string { - days := int(d.Hours() / 24) - hours := int(d.Hours()) % 24 - minutes := int(d.Minutes()) % 60 - - if days > 0 { - return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) - } else if hours > 0 { - return fmt.Sprintf("%dh %dm", hours, minutes) - } - return fmt.Sprintf("%dm", minutes) -} - -func ApplyLetterFilterOverrides(ctx context.Context, filter *repository.ListOutgoingLettersFilter) { - appCtx := appcontext.FromGinContext(ctx) - if appCtx == nil { - return - } - - isSuperAdmin := false - if appCtx.UserRole == "superadmin" || appCtx.UserRole == "admin" { - isSuperAdmin = true - } - - if !isSuperAdmin && appCtx.UserID != uuid.Nil { - filter.UserID = &appCtx.UserID - } -} - -func (s *LetterOutgoingServiceImpl) BulkArchiveOutgoingLetters(ctx context.Context, letterIDs []uuid.UUID) (*contract.BulkArchiveLettersResponse, error) { - userID := appcontext.FromGinContext(ctx).UserID - - 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 -} - -func (s *LetterOutgoingServiceImpl) sendStepApprovalNotifications(ctx context.Context, letterID uuid.UUID, subject string, stepOrder int) { - log.Printf("[DEBUG] sendStepApprovalNotifications START - LetterID: %s, StepOrder: %d", letterID.String(), stepOrder) - - // Get the letter to know the current revision - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - log.Printf("[ERROR] Failed to get letter: %v", err) - return - } - - // Get approvals for the current revision only - approvals, err := s.processor.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) - if err != nil { - log.Printf("[ERROR] Failed to get approvals: %v", err) - return - } - - log.Printf("[DEBUG] Found %d approvals", len(approvals)) - - // Find approvers for the specified step - for _, approval := range approvals { - log.Printf("[DEBUG] Checking approval: Step=%d, Status=%s, ApproverID=%v", - approval.StepOrder, approval.Status, approval.ApproverID) - - if approval.StepOrder == stepOrder { - log.Printf("[DEBUG] Sending notification to approver %s for step %d", approval.ApproverID.String(), stepOrder) - - err := s.notificationProcessor.SendOutgoingLetterNotification( - ctx, - letterID, - *approval.ApproverID, - "Surat Keluar Perlu Persetujuan", - fmt.Sprintf("Surat keluar '%s' memerlukan persetujuan Anda pada tahap %d", subject, stepOrder)) - - if err != nil { - log.Printf("[ERROR] Failed to send notification to approver %s: %v", approval.ApproverID.String(), err) - } else { - log.Printf("[DEBUG] Successfully sent notification to approver %s", approval.ApproverID.String()) - } - } - } -} - -// Kirim notifikasi ke creator -func (s *LetterOutgoingServiceImpl) sendApprovalNotificationToCreator(ctx context.Context, letterID uuid.UUID, creatorID uuid.UUID, title string, message string) { - log.Printf("[DEBUG] sendApprovalNotificationToCreator START - LetterID: %s, CreatorID: %s", letterID.String(), creatorID.String()) - - err := s.notificationProcessor.SendOutgoingLetterNotification( - ctx, - letterID, - creatorID, - title, - message) - - if err != nil { - log.Printf("[ERROR] Failed to send notification to creator %s: %v", creatorID.String(), err) - } else { - log.Printf("[DEBUG] Successfully sent notification to creator %s", creatorID.String()) - } -} - -// Helper function to get the next parallel group number (handles non-sequential groups) -func (s *LetterOutgoingServiceImpl) getNextParallelGroup(approvals []entities.LetterOutgoingApproval, currentGroup int) int { - // Collect all unique parallel groups - groupsMap := make(map[int]bool) - for _, approval := range approvals { - groupsMap[approval.ParallelGroup] = true - } - - // Convert to sorted slice - var groups []int - for group := range groupsMap { - groups = append(groups, group) - } - sort.Ints(groups) - - // Find the next group after current - for _, group := range groups { - if group > currentGroup { - // Check if this group has any pending approvals - for _, approval := range approvals { - if approval.ParallelGroup == group && approval.Status == entities.ApprovalStatusNotStarted { - return group - } - } - } - } - - return 0 // No next group -} - -// Send notifications to approvers in a specific parallel group -func (s *LetterOutgoingServiceImpl) sendParallelGroupApprovalNotifications(ctx context.Context, letterID uuid.UUID, subject string, parallelGroup int) { - log.Printf("[DEBUG] sendParallelGroupApprovalNotifications START - LetterID: %s, ParallelGroup: %d", letterID.String(), parallelGroup) - - // Get the letter to know the current revision - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - log.Printf("[ERROR] Failed to get letter: %v", err) - return - } - - // Get approvals for the current revision only - approvals, err := s.processor.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) - if err != nil { - log.Printf("[ERROR] Failed to get approvals: %v", err) - return - } - - log.Printf("[DEBUG] Found %d approvals", len(approvals)) - - // Find approvers for the specified parallel group - for _, approval := range approvals { - log.Printf("[DEBUG] Checking approval: ParallelGroup=%d, Status=%s, ApproverID=%v", - approval.ParallelGroup, approval.Status, approval.ApproverID) - - if approval.ParallelGroup == parallelGroup && approval.ApproverID != nil { - log.Printf("[DEBUG] Sending notification to approver %s for parallel group %d", approval.ApproverID.String(), parallelGroup) - - err := s.notificationProcessor.SendOutgoingLetterNotification( - ctx, - letterID, - *approval.ApproverID, - "Surat Keluar Perlu Persetujuan", - fmt.Sprintf("Surat keluar '%s' memerlukan persetujuan Anda pada grup %d", subject, parallelGroup)) - - if err != nil { - log.Printf("[ERROR] Failed to send notification to approver %s: %v", approval.ApproverID.String(), err) - } else { - log.Printf("[DEBUG] Successfully sent notification to approver %s", approval.ApproverID.String()) - } - } - } -} - -func (s *LetterOutgoingServiceImpl) sendOutgoingDiscussionMentionNotifications(ctx context.Context, letterID uuid.UUID, senderUserID uuid.UUID, mentions map[string]interface{}, message string) { - log.Printf("[DEBUG] sendOutgoingDiscussionMentionNotifications START - LetterID: %s", letterID.String()) - - // Extract user_ids dari mentions - userIDs := s.extractUserIDsFromMentions(mentions) - if len(userIDs) == 0 { - log.Printf("[DEBUG] No user IDs found in mentions") - return - } - - log.Printf("[DEBUG] Found %d mentioned users", len(userIDs)) - - // Get letter details untuk notification - letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) - if err != nil { - log.Printf("[ERROR] Failed to get letter details: %v", err) - return - } - - // Get sender user name dari context (bisa juga dari user service) - appContext := appcontext.FromGinContext(ctx) - senderName := appContext.UserName - if senderName == "" { - senderName = "Seseorang" // fallback jika nama tidak tersedia - } - - // Kirim notification ke setiap mentioned user - for _, mentionedUserID := range userIDs { - // Jangan kirim notification ke sender sendiri - if mentionedUserID == senderUserID { - continue - } - - subject := "Anda Disebutkan dalam Diskusi Surat Keluar" - notificationMessage := fmt.Sprintf("%s menyebutkan Anda dalam diskusi surat keluar: %s", senderName, letter.Subject) - - err := s.notificationProcessor.SendOutgoingLetterNotification( - ctx, - letterID, - mentionedUserID, - subject, - notificationMessage) - - if err != nil { - log.Printf("[ERROR] Failed to send mention notification to user %s: %v", mentionedUserID.String(), err) - } else { - log.Printf("[DEBUG] Successfully sent mention notification to user %s", mentionedUserID.String()) - } - } -} - -// Helper function untuk extract user IDs dari mentions map -func (s *LetterOutgoingServiceImpl) extractUserIDsFromMentions(mentions map[string]interface{}) []uuid.UUID { - userIDs := make([]uuid.UUID, 0) - - if mentions == nil { - return userIDs - } - - if userIDsInterface, exists := mentions["user_ids"]; exists { - switch userIDsValue := userIDsInterface.(type) { - case []interface{}: - for _, userIDInterface := range userIDsValue { - if userIDStr, ok := userIDInterface.(string); ok { - if userID, err := uuid.Parse(userIDStr); err == nil { - userIDs = append(userIDs, userID) - } - } - } - case []string: - for _, userIDStr := range userIDsValue { - if userID, err := uuid.Parse(userIDStr); err == nil { - userIDs = append(userIDs, userID) - } - } - case []uuid.UUID: - userIDs = userIDsValue - } - } - - return userIDs -} diff --git a/internal/service/letter_service.go b/internal/service/letter_service.go deleted file mode 100644 index 6292adf..0000000 --- a/internal/service/letter_service.go +++ /dev/null @@ -1,1013 +0,0 @@ -package service - -import ( - "context" - "fmt" - "log" - "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, filter repository.ListIncomingLettersFilter, page, limit int) ([]entities.LetterIncoming, int64, error) - SearchIncomingLetters(ctx context.Context, filters map[string]interface{}, page, limit int, sortBy, sortOrder string) ([]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 - BulkSoftDeleteIncomingLetters(ctx context.Context, ids []uuid.UUID) error - BulkArchiveIncomingLetters(ctx context.Context, letterIDs []uuid.UUID) (int64, error) - ArchiveIncomingLetter(ctx context.Context, letterID uuid.UUID) 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) - GetBatchDispositions(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterIncomingDisposition, 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, string, 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 - txManager *repository.TxManager - numberGenerator NumberGenerator - recipientProcessor RecipientProcessor - activityLogger ActivityLogger - letterDispositionProcessor LetterDispositionProcessor - notificationProcessor processor.NotificationProcessor - activityProcessor ActivityLogger -} - -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) - CreateRecipients(ctx context.Context, letterID uuid.UUID, departmentIDs []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, - activityProc ActivityLogger, -) *LetterServiceImpl { - return &LetterServiceImpl{ - processor: processor, - txManager: txManager, - numberGenerator: numberGenerator, - recipientProcessor: recipientProcessor, - activityLogger: activityLogger, - letterDispositionProcessor: letterDispositionProcessor, - notificationProcessor: notificationProcessor, - activityProcessor: activityProc, - } -} - -func (s *LetterServiceImpl) CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) { - 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 - } - - 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 { - // Log error but don't fail the operation - } -} - -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 { - // Failed to add creator as recipient - return nil, err - } - - return &recipient, nil -} - -func (s *LetterServiceImpl) sendLetterNotifications(ctx context.Context, letter *contract.IncomingLetterResponse, recipients []entities.LetterIncomingRecipient) { - for _, recipient := range recipients { - if recipient.Status != "completed" { - err := s.notificationProcessor.SendIncomingLetterNotification( - ctx, - letter.ID, - *recipient.RecipientUserID, - "Surat Masuk", - fmt.Sprintf("%s: %s", letter.SenderInstitution.Name, letter.Subject)) - - if err != nil { - // Failed to send notification, continue anyway - } - } - } -} - -func (s *LetterServiceImpl) sendDispositionNotifications(ctx context.Context, letterID uuid.UUID, recipients []entities.LetterIncomingRecipient) { - // Get letter details for notification - appContext := appcontext.FromGinContext(ctx) - letter, err := s.processor.GetIncomingLetterByID(ctx, letterID) - if err != nil { - return - } - - for _, recipient := range recipients { - if recipient.RecipientUserID != nil && recipient.Status != entities.RecipientStatusCompleted { - subject := "Surat Masuk" - message := fmt.Sprintf("Disposisi surat dari %s: %s", appContext.UserName, letter.Subject) - - err := s.notificationProcessor.SendIncomingLetterNotification( - ctx, - letterID, - *recipient.RecipientUserID, - subject, - message) - - if err != nil { - // Failed to send notification, continue anyway - } - } - } -} - -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) { - 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 - dispostions map[uuid.UUID][]entities.LetterIncomingDisposition - 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), - dispostions: make(map[uuid.UUID][]entities.LetterIncomingDisposition), - } - - errChan := make(chan error, 5) - - 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 - }() - - go func() { - var err error - result.dispostions, err = s.processor.GetBatchDispositions(ctx, letterIDs) - errChan <- err - }() - - for i := 0; i < 5; i++ { - if err := <-errChan; err != nil { - // Batch load error, continue anyway - } - } - - 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] - } - - dispositions := batchData.dispostions[letter.ID] - if dispositions == nil { - dispositions = []entities.LetterIncomingDisposition{} - } - - isRead := false - if recipient, exists := batchData.recipients[letter.ID]; exists && recipient != nil { - isRead = recipient.ReadAt != nil - log.Printf("Recipient debug: %+v\n", recipient) - } - - resp := transformer.LetterEntityToContract(&letter, attachments, dispositions, 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) -} -func (s *LetterServiceImpl) SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error { - return s.processor.SoftDeleteIncomingLetter(ctx, id) -} - -func (s *LetterServiceImpl) BulkSoftDeleteIncomingLetters(ctx context.Context, ids []uuid.UUID) error { - return s.processor.BulkSoftDeleteIncomingLetters(ctx, ids) -} - -func (s *LetterServiceImpl) SearchIncomingLetters(ctx context.Context, req *contract.SearchIncomingLettersRequest) (*contract.SearchIncomingLettersResponse, error) { - appCtx := appcontext.FromGinContext(ctx) - userID := appCtx.UserID - departmentID := appCtx.DepartmentID - - // Build search filters - filters := buildIncomingSearchFilters(req, userID, departmentID) - - // Execute search with pagination - letters, total, err := s.processor.SearchIncomingLetters(ctx, filters, req.Page, req.Limit, req.SortBy, req.SortOrder) - if err != nil { - return nil, err - } - - // Collect IDs for batch loading - letterIDs := make([]uuid.UUID, len(letters)) - priorityIDMap := make(map[uuid.UUID]bool) - institutionIDMap := make(map[uuid.UUID]bool) - - for i, letter := range letters { - letterIDs[i] = letter.ID - if letter.PriorityID != nil { - priorityIDMap[*letter.PriorityID] = true - } - if letter.SenderInstitutionID != nil { - institutionIDMap[*letter.SenderInstitutionID] = true - } - } - - // Convert maps to slices - priorityIDSlice := make([]uuid.UUID, 0, len(priorityIDMap)) - for id := range priorityIDMap { - priorityIDSlice = append(priorityIDSlice, id) - } - - institutionIDSlice := make([]uuid.UUID, 0, len(institutionIDMap)) - for id := range institutionIDMap { - institutionIDSlice = append(institutionIDSlice, id) - } - - // Parallel batch loading - type batchLoadResult struct { - attachments map[uuid.UUID][]entities.LetterIncomingAttachment - recipients map[uuid.UUID]*entities.LetterIncomingRecipient - priorities map[uuid.UUID]*entities.Priority - institutions map[uuid.UUID]*entities.Institution - } - - var result batchLoadResult - errChan := make(chan error, 4) - - // Load attachments - go func() { - result.attachments, err = s.processor.GetBatchAttachments(ctx, letterIDs) - errChan <- err - }() - - // Load recipients for user - go func() { - result.recipients, err = s.processor.GetBatchRecipientsByUser(ctx, letterIDs, userID) - 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.IncomingLetterResponse, len(letters)) - for i, letter := range letters { - // Attach batch loaded data - attachmentResponses := []contract.IncomingLetterAttachmentResponse{} - if attachments, ok := result.attachments[letter.ID]; ok { - for _, att := range attachments { - attachmentResponses = append(attachmentResponses, contract.IncomingLetterAttachmentResponse{ - ID: att.ID, - FileURL: att.FileURL, - FileName: att.FileName, - FileType: att.FileType, - UploadedAt: att.UploadedAt, - }) - } - } - - var priorityResp *contract.PriorityResponse - if letter.PriorityID != nil { - if priority, ok := result.priorities[*letter.PriorityID]; ok { - priorityResp = &contract.PriorityResponse{ - ID: priority.ID.String(), - Name: priority.Name, - Level: priority.Level, - CreatedAt: priority.CreatedAt, - UpdatedAt: priority.UpdatedAt, - } - } - } - - var institutionResp *contract.InstitutionResponse - if letter.SenderInstitutionID != nil { - if institution, ok := result.institutions[*letter.SenderInstitutionID]; ok { - institutionResp = &contract.InstitutionResponse{ - ID: institution.ID.String(), - Name: institution.Name, - Type: string(institution.Type), - Address: institution.Address, - ContactPerson: institution.ContactPerson, - Phone: institution.Phone, - Email: institution.Email, - CreatedAt: institution.CreatedAt, - UpdatedAt: institution.UpdatedAt, - } - } - } - - isRead := false - if recipient, ok := result.recipients[letter.ID]; ok && recipient.ReadAt != nil { - isRead = true - } - - items[i] = contract.IncomingLetterResponse{ - ID: letter.ID, - LetterNumber: letter.LetterNumber, - ReferenceNumber: letter.ReferenceNumber, - Subject: letter.Subject, - Description: letter.Description, - Priority: priorityResp, - SenderInstitution: institutionResp, - SenderName: letter.SenderName, - ReceivedDate: letter.ReceivedDate, - DueDate: letter.DueDate, - Status: string(letter.Status), - CreatedBy: letter.CreatedBy, - CreatedAt: letter.CreatedAt, - UpdatedAt: letter.UpdatedAt, - Attachments: attachmentResponses, - IsRead: isRead, - } - } - - return &contract.SearchIncomingLettersResponse{ - Letters: items, - TotalCount: total, - Page: req.Page, - Limit: req.Limit, - }, nil -} - -func buildIncomingSearchFilters(req *contract.SearchIncomingLettersRequest, userID, departmentID uuid.UUID) map[string]interface{} { - filters := make(map[string]interface{}) - - if req.Query != "" { - filters["query"] = req.Query - } - if req.LetterNumber != "" { - filters["letter_number"] = req.LetterNumber - } - if req.Subject != "" { - filters["subject"] = req.Subject - } - if req.Status != "" { - filters["status"] = req.Status - } - if req.PriorityID != nil { - filters["priority_id"] = *req.PriorityID - } - if req.InstitutionID != nil { - filters["sender_institution_id"] = *req.InstitutionID - } - if req.CreatedBy != nil { - filters["created_by"] = *req.CreatedBy - } - if req.DateFrom != nil { - filters["date_from"] = *req.DateFrom - } - if req.DateTo != nil { - filters["date_to"] = *req.DateTo - } - - // Add user/department context filters - filters["user_context"] = map[string]interface{}{ - "user_id": userID, - "department_id": departmentID, - } - - return filters -} - -func (s *LetterServiceImpl) CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) { - log.Printf("[DEBUG] CreateDispositions START - LetterID: %s\n", req.LetterID.String()) - userID := appcontext.FromGinContext(ctx).UserID - req.CreatedBy = userID - - if req.FromDepartment == uuid.Nil { - req.FromDepartment = appcontext.FromGinContext(ctx).DepartmentID - } - - var result *contract.ListDispositionsResponse - var recipients []entities.LetterIncomingRecipient - - err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - var err error - result, err = s.processor.CreateDispositions(txCtx, req) - if err != nil { - return err - } - - if len(req.ToDepartmentIDs) > 0 && s.recipientProcessor != nil { - recipients, err = s.recipientProcessor.CreateRecipients(txCtx, req.LetterID, req.ToDepartmentIDs) - if err != nil { - return err - } - } - - if s.activityLogger != nil && result != nil && len(result.Dispositions) > 0 { - if err := s.activityLogger.LogLetterDispositionStatusUpdate(txCtx, req.LetterID, userID, "disposition_created"); err != nil { - - } - } - - return nil - }) - - if err != nil { - return nil, err - } - - // Send notifications to newly created recipients asynchronously - if s.notificationProcessor != nil { - // Send notifications to newly created recipients - if len(recipients) > 0 { - go s.sendDispositionNotifications(context.Background(), req.LetterID, recipients) - } - - // Send notification to letter creator about new disposition - go s.sendDispositionCreatorNotification(context.Background(), req.LetterID, userID) - } - - return result, nil -} - -func (s *LetterServiceImpl) GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) { - return s.processor.GetEnhancedDispositionsByLetter(ctx, letterID) -} - -func (s *LetterServiceImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) { - userID := appcontext.FromGinContext(ctx).UserID - - var result *contract.LetterDiscussionResponse - err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - var err error - result, err = s.processor.CreateDiscussion(txCtx, letterID, req) - if err != nil { - return err - } - - // Log activity for discussion creation - if s.activityLogger != nil && result != nil { - if err := s.activityLogger.LogLetterDispositionStatusUpdate(txCtx, letterID, userID, "discussion_created"); err != nil { - // Don't fail the transaction for logging errors - } - } - - return nil - }) - - if err != nil { - return nil, err - } - - // Send notifications to mentioned users asynchronously - if s.notificationProcessor != nil && req.Mentions != nil { - go s.sendDiscussionMentionNotifications(context.Background(), letterID, userID, req.Mentions, req.Message) - } - - return result, nil -} - -func (s *LetterServiceImpl) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) { - userID := appcontext.FromGinContext(ctx).UserID - - var result *contract.LetterDiscussionResponse - - err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - var err error - var oldMessage string - result, oldMessage, err = s.processor.UpdateDiscussion(txCtx, letterID, discussionID, req) - if err != nil { - return err - } - - // Log activity for discussion update (could use oldMessage for more detailed logging) - if s.activityLogger != nil && result != nil { - // Create a simple activity log - oldMessage could be included in a more detailed log - _ = oldMessage // Mark as intentionally unused for now - if err := s.activityLogger.LogLetterDispositionStatusUpdate(txCtx, letterID, userID, "discussion_updated"); err != nil { - // Don't fail the transaction for logging errors - } - } - - return nil - }) - - if err != nil { - return nil, err - } - - return result, nil -} - -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) { - var result *contract.DepartmentDispositionStatusResponse - - err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - var err error - result, err = s.processor.UpdateDispositionStatus(txCtx, req) - if err != nil { - return err - } - - // Log activity for disposition status update - if s.activityLogger != nil && result != nil { - userID := appcontext.FromGinContext(txCtx).UserID - if err := s.activityLogger.LogLetterDispositionStatusUpdate(txCtx, req.LetterIncomingID, userID, req.Status); err != nil { - // Don't fail the transaction for logging errors - } - } - - return nil - }) - - if err != nil { - return nil, err - } - - // Send notification to letter creator asynchronously - if s.notificationProcessor != nil && result != nil { - go s.sendDispositionStatusUpdateNotification(context.Background(), req.LetterIncomingID, req.Status) - } - - return result, nil -} - -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) { - userID := appcontext.FromGinContext(ctx).UserID - - // Archive the letters themselves - 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 -} - -func (s *LetterServiceImpl) ArchiveIncomingLetter(ctx context.Context, letterID uuid.UUID) error { - return s.processor.ArchiveIncomingLetter(ctx, letterID) -} - -func (s *LetterServiceImpl) sendDiscussionMentionNotifications(ctx context.Context, letterID uuid.UUID, senderUserID uuid.UUID, mentions map[string]interface{}, message string) { - // Extract user_ids from mentions - userIDs := s.extractUserIDsFromMentions(mentions) - if len(userIDs) == 0 { - return - } - - // Get letter details for notification - letter, err := s.processor.GetIncomingLetterByID(ctx, letterID) - if err != nil { - return - } - - // Get sender user name (you might need to implement this) - appContext := appcontext.FromGinContext(ctx) - senderName := appContext.UserName // or get from user service - - // Send notification to each mentioned user - for _, mentionedUserID := range userIDs { - // Don't send notification to the sender themselves - if mentionedUserID == senderUserID { - continue - } - - subject := "Anda Disebutkan dalam Diskusi" - notificationMessage := fmt.Sprintf("%s menyebutkan Anda dalam diskusi surat: %s", senderName, letter.Subject) - - err := s.notificationProcessor.SendIncomingLetterNotification( - ctx, - letterID, - mentionedUserID, - subject, - notificationMessage) - - if err != nil { - // Log error but continue with other notifications - } - } -} - -func (s *LetterServiceImpl) extractUserIDsFromMentions(mentions map[string]interface{}) []uuid.UUID { - userIDs := make([]uuid.UUID, 0) - - if userIDsInterface, exists := mentions["user_ids"]; exists { - switch userIDsValue := userIDsInterface.(type) { - case []interface{}: - for _, userIDInterface := range userIDsValue { - if userIDStr, ok := userIDInterface.(string); ok { - if userID, err := uuid.Parse(userIDStr); err == nil { - userIDs = append(userIDs, userID) - } - } - } - case []string: - for _, userIDStr := range userIDsValue { - if userID, err := uuid.Parse(userIDStr); err == nil { - userIDs = append(userIDs, userID) - } - } - } - } - - return userIDs -} - -func (s *LetterServiceImpl) sendDispositionCreatorNotification(ctx context.Context, letterID uuid.UUID, dispositionCreatorID uuid.UUID) { - // Get letter details - letter, err := s.processor.GetIncomingLetterByID(ctx, letterID) - if err != nil { - return - } - - fmt.Printf("[DEBUG] Starting sendDispositionCreatorNotification for letterID: %s\n", letterID.String()) - fmt.Printf("[DEBUG] Successfully retrieved letter: %s\n", letter.Subject) - fmt.Printf("[DEBUG] Successfully retrieved letter: %s\n", letter.CreatedBy) - - letterCreatorID := letter.CreatedBy - - // Don't send notification if the disposition creator is the same as letter creator - if letterCreatorID == dispositionCreatorID { - return - } - - // Get disposition creator name from context - appContext := appcontext.FromGinContext(ctx) - dispositionCreatorName := appContext.UserName - - subject := "Disposisi Baru pada Surat Anda" - message := fmt.Sprintf("Surat yang Anda buat telah didisposisikan %s: %s", - dispositionCreatorName, letter.Subject) - - err = s.notificationProcessor.SendIncomingLetterNotification( - ctx, - letterID, - letterCreatorID, - subject, - message) - - if err != nil { - // Log error but don't fail the operation - // You might want to add proper logging here - } -} - -func (s *LetterServiceImpl) sendDispositionStatusUpdateNotification(ctx context.Context, letterID uuid.UUID, newStatus string) { - // Get letter details - letter, err := s.processor.GetIncomingLetterByID(ctx, letterID) - if err != nil { - // Log error but don't fail - return - } - - // Get current user context (the one updating the status) - appContext := appcontext.FromGinContext(ctx) - updaterUserID := appContext.UserID - updaterName := appContext.UserName - - letterCreatorID := letter.CreatedBy - - // Don't send notification if the updater is the same as letter creator - if letterCreatorID == updaterUserID { - return - } - - // Create status-specific notification message - var statusMessage string - switch newStatus { - case "pending": - statusMessage = "sedang menunggu" - case "in_progress": - statusMessage = "sedang diproses" - case "completed": - statusMessage = "telah diselesaikan" - case "cancelled": - statusMessage = "dibatalkan" - default: - statusMessage = fmt.Sprintf("diubah statusnya menjadi %s", newStatus) - } - - subject := "Status Disposisi Surat Diperbarui" - message := fmt.Sprintf("Disposisi surat '%s' %s %s", - letter.Subject, statusMessage, updaterName) - - err = s.notificationProcessor.SendIncomingLetterNotification( - ctx, - letterID, - letterCreatorID, - subject, - message) - - if err != nil { - // Log error but don't fail the operation - // You might want to add proper logging here - } -} diff --git a/internal/service/master_service.go b/internal/service/master_service.go deleted file mode 100644 index 31b8ca0..0000000 --- a/internal/service/master_service.go +++ /dev/null @@ -1,633 +0,0 @@ -package service - -import ( - "context" - "sort" - "strings" - - "eslogad-be/config" - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" - "eslogad-be/internal/transformer" - - "github.com/google/uuid" -) - -type MasterServiceImpl struct { - labelRepo *repository.LabelRepository - priorityRepo *repository.PriorityRepository - institutionRepo *repository.InstitutionRepository - dispRepo *repository.DispositionActionRepository - departmentRepo *repository.DepartmentRepository - config *config.Config -} - -func NewMasterService(label *repository.LabelRepository, priority *repository.PriorityRepository, institution *repository.InstitutionRepository, disp *repository.DispositionActionRepository, department *repository.DepartmentRepository, cfg *config.Config) *MasterServiceImpl { - return &MasterServiceImpl{labelRepo: label, priorityRepo: priority, institutionRepo: institution, dispRepo: disp, departmentRepo: department, config: cfg} -} - -// Labels -func (s *MasterServiceImpl) CreateLabel(ctx context.Context, req *contract.CreateLabelRequest) (*contract.LabelResponse, error) { - entity := &entities.Label{Name: req.Name, Color: req.Color} - if err := s.labelRepo.Create(ctx, entity); err != nil { - return nil, err - } - resp := transformer.LabelsToContract([]entities.Label{*entity})[0] - return &resp, nil -} -func (s *MasterServiceImpl) UpdateLabel(ctx context.Context, id uuid.UUID, req *contract.UpdateLabelRequest) (*contract.LabelResponse, error) { - entity := &entities.Label{ID: id} - if req.Name != nil { - entity.Name = *req.Name - } - if req.Color != nil { - entity.Color = req.Color - } - if err := s.labelRepo.Update(ctx, entity); err != nil { - return nil, err - } - e, err := s.labelRepo.Get(ctx, id) - if err != nil { - return nil, err - } - resp := transformer.LabelsToContract([]entities.Label{*e})[0] - return &resp, nil -} -func (s *MasterServiceImpl) DeleteLabel(ctx context.Context, id uuid.UUID) error { - return s.labelRepo.Delete(ctx, id) -} -func (s *MasterServiceImpl) ListLabels(ctx context.Context) (*contract.ListLabelsResponse, error) { - list, err := s.labelRepo.List(ctx) - if err != nil { - return nil, err - } - return &contract.ListLabelsResponse{Labels: transformer.LabelsToContract(list)}, nil -} - -// Priorities -func (s *MasterServiceImpl) CreatePriority(ctx context.Context, req *contract.CreatePriorityRequest) (*contract.PriorityResponse, error) { - entity := &entities.Priority{Name: req.Name, Level: req.Level} - if err := s.priorityRepo.Create(ctx, entity); err != nil { - return nil, err - } - resp := transformer.PrioritiesToContract([]entities.Priority{*entity})[0] - return &resp, nil -} -func (s *MasterServiceImpl) UpdatePriority(ctx context.Context, id uuid.UUID, req *contract.UpdatePriorityRequest) (*contract.PriorityResponse, error) { - entity := &entities.Priority{ID: id} - if req.Name != nil { - entity.Name = *req.Name - } - if req.Level != nil { - entity.Level = *req.Level - } - if err := s.priorityRepo.Update(ctx, entity); err != nil { - return nil, err - } - e, err := s.priorityRepo.Get(ctx, id) - if err != nil { - return nil, err - } - resp := transformer.PrioritiesToContract([]entities.Priority{*e})[0] - return &resp, nil -} -func (s *MasterServiceImpl) DeletePriority(ctx context.Context, id uuid.UUID) error { - return s.priorityRepo.Delete(ctx, id) -} -func (s *MasterServiceImpl) ListPriorities(ctx context.Context) (*contract.ListPrioritiesResponse, error) { - list, err := s.priorityRepo.List(ctx) - if err != nil { - return nil, err - } - return &contract.ListPrioritiesResponse{Priorities: transformer.PrioritiesToContract(list)}, nil -} - -// Institutions -func (s *MasterServiceImpl) CreateInstitution(ctx context.Context, req *contract.CreateInstitutionRequest) (*contract.InstitutionResponse, error) { - entity := &entities.Institution{Name: req.Name, Type: entities.InstitutionType(req.Type), Address: req.Address, ContactPerson: req.ContactPerson, Phone: req.Phone, Email: req.Email} - if err := s.institutionRepo.Create(ctx, entity); err != nil { - return nil, err - } - resp := transformer.InstitutionsToContract([]entities.Institution{*entity})[0] - return &resp, nil -} -func (s *MasterServiceImpl) UpdateInstitution(ctx context.Context, id uuid.UUID, req *contract.UpdateInstitutionRequest) (*contract.InstitutionResponse, error) { - entity := &entities.Institution{ID: id} - if req.Name != nil { - entity.Name = *req.Name - } - if req.Type != nil { - entity.Type = entities.InstitutionType(*req.Type) - } - if req.Address != nil { - entity.Address = req.Address - } - if req.ContactPerson != nil { - entity.ContactPerson = req.ContactPerson - } - if req.Phone != nil { - entity.Phone = req.Phone - } - if req.Email != nil { - entity.Email = req.Email - } - if err := s.institutionRepo.Update(ctx, entity); err != nil { - return nil, err - } - e, err := s.institutionRepo.Get(ctx, id) - if err != nil { - return nil, err - } - resp := transformer.InstitutionsToContract([]entities.Institution{*e})[0] - return &resp, nil -} -func (s *MasterServiceImpl) DeleteInstitution(ctx context.Context, id uuid.UUID) error { - return s.institutionRepo.Delete(ctx, id) -} -func (s *MasterServiceImpl) ListInstitutions(ctx context.Context, req *contract.ListInstitutionsRequest) (*contract.ListInstitutionsResponse, error) { - list, err := s.institutionRepo.ListWithSearch(ctx, req.Search) - if err != nil { - return nil, err - } - return &contract.ListInstitutionsResponse{Institutions: transformer.InstitutionsToContract(list)}, nil -} - -// Disposition Actions -func (s *MasterServiceImpl) CreateDispositionAction(ctx context.Context, req *contract.CreateDispositionActionRequest) (*contract.DispositionActionResponse, error) { - entity := &entities.DispositionAction{Code: req.Code, Label: req.Label, Description: req.Description} - if req.RequiresNote != nil { - entity.RequiresNote = *req.RequiresNote - } - if req.GroupName != nil { - entity.GroupName = req.GroupName - } - if req.SortOrder != nil { - entity.SortOrder = req.SortOrder - } - if req.IsActive != nil { - entity.IsActive = *req.IsActive - } - if err := s.dispRepo.Create(ctx, entity); err != nil { - return nil, err - } - resp := transformer.DispositionActionsToContract([]entities.DispositionAction{*entity})[0] - return &resp, nil -} -func (s *MasterServiceImpl) UpdateDispositionAction(ctx context.Context, id uuid.UUID, req *contract.UpdateDispositionActionRequest) (*contract.DispositionActionResponse, error) { - entity := &entities.DispositionAction{ID: id} - if req.Code != nil { - entity.Code = *req.Code - } - if req.Label != nil { - entity.Label = *req.Label - } - if req.Description != nil { - entity.Description = req.Description - } - if req.RequiresNote != nil { - entity.RequiresNote = *req.RequiresNote - } - if req.GroupName != nil { - entity.GroupName = req.GroupName - } - if req.SortOrder != nil { - entity.SortOrder = req.SortOrder - } - if req.IsActive != nil { - entity.IsActive = *req.IsActive - } - if err := s.dispRepo.Update(ctx, entity); err != nil { - return nil, err - } - e, err := s.dispRepo.Get(ctx, id) - if err != nil { - return nil, err - } - resp := transformer.DispositionActionsToContract([]entities.DispositionAction{*e})[0] - return &resp, nil -} -func (s *MasterServiceImpl) DeleteDispositionAction(ctx context.Context, id uuid.UUID) error { - return s.dispRepo.Delete(ctx, id) -} -func (s *MasterServiceImpl) ListDispositionActions(ctx context.Context) (*contract.ListDispositionActionsResponse, error) { - list, err := s.dispRepo.List(ctx) - if err != nil { - return nil, err - } - return &contract.ListDispositionActionsResponse{Actions: transformer.DispositionActionsToContract(list)}, nil -} - -// Departments -func (s *MasterServiceImpl) CreateDepartment(ctx context.Context, req *contract.CreateDepartmentRequest) (*contract.GetDepartmentResponse, error) { - // Build the path based on parent - var path string - if req.ParentID != nil { - // Get parent department to build the path - parent, err := s.departmentRepo.GetByID(ctx, *req.ParentID) - if err != nil { - return nil, err - } - // Build path as parent.path + code - path = parent.Path + "." + req.Code - } else { - // Root level department, just use the code as path - path = req.Code - } - - entity := &entities.Department{ - Name: req.Name, - Code: req.Code, - Path: path, - } - if err := s.departmentRepo.Create(ctx, entity); err != nil { - return nil, err - } - // Get parent name if parent exists - var parentName *string - if req.ParentID != nil { - if parent, err := s.departmentRepo.GetByID(ctx, *req.ParentID); err == nil { - parentName = &parent.Name - } - } - - return &contract.GetDepartmentResponse{ - ID: entity.ID, - Name: entity.Name, - Code: entity.Code, - Path: entity.Path, - ParentID: req.ParentID, - ParentName: parentName, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, - }, nil -} - -func (s *MasterServiceImpl) GetDepartment(ctx context.Context, id uuid.UUID) (*contract.GetDepartmentResponse, error) { - entity, err := s.departmentRepo.Get(ctx, id) - if err != nil { - return nil, err - } - - // Derive parent_id and parent_name from path - var parentID *uuid.UUID - var parentName *string - parts := strings.Split(entity.Path, ".") - if len(parts) > 1 { - // Has parent, try to find it - parentPath := strings.Join(parts[:len(parts)-1], ".") - if parent, err := s.departmentRepo.GetByPath(ctx, parentPath); err == nil { - parentID = &parent.ID - parentName = &parent.Name - } - } - - return &contract.GetDepartmentResponse{ - ID: entity.ID, - Name: entity.Name, - Code: entity.Code, - Path: entity.Path, - ParentID: parentID, - ParentName: parentName, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, - }, nil -} - -func (s *MasterServiceImpl) UpdateDepartment(ctx context.Context, id uuid.UUID, req *contract.UpdateDepartmentRequest) (*contract.GetDepartmentResponse, error) { - entity, err := s.departmentRepo.Get(ctx, id) - if err != nil { - return nil, err - } - - // Store the old path before changes - oldPath := entity.Path - - if req.Name != nil { - entity.Name = *req.Name - } - if req.Code != nil { - entity.Code = *req.Code - } - - // Rebuild path if parent is being changed or code is being changed - if req.ParentID != nil || req.Code != nil { - // Determine the code to use (new code if provided, otherwise existing) - code := entity.Code - if req.Code != nil { - code = *req.Code - } - - // Build the new path based on parent - var path string - if req.ParentID != nil { - if *req.ParentID == uuid.Nil { - // Moving to root level - path = code - } else { - // Get parent department to build the path - parent, err := s.departmentRepo.GetByID(ctx, *req.ParentID) - if err != nil { - return nil, err - } - // Build path as parent.path + code - path = parent.Path + "." + code - } - } else if req.Code != nil { - // Code changed but parent not specified, rebuild path with current parent - // Extract parent path from current path - parts := strings.Split(entity.Path, ".") - if len(parts) > 1 { - // Has parent, rebuild with new code - parentPath := strings.Join(parts[:len(parts)-1], ".") - path = parentPath + "." + code - } else { - // Root level, just use new code - path = code - } - } - - if path != "" { - entity.Path = path - } - } - - // Update the department - if err := s.departmentRepo.Update(ctx, entity); err != nil { - return nil, err - } - - // If the path changed, update all children paths - if oldPath != entity.Path { - if err := s.departmentRepo.UpdateChildrenPaths(ctx, oldPath, entity.Path); err != nil { - // Log the error but don't fail the operation - // You might want to handle this differently based on your requirements - // For now, we'll continue since the parent update succeeded - } - } - - // Derive parent_id and parent_name from path for response - var parentID *uuid.UUID - var parentName *string - if req.ParentID != nil { - parentID = req.ParentID - // Get parent name - if parent, err := s.departmentRepo.GetByID(ctx, *req.ParentID); err == nil { - parentName = &parent.Name - } - } else { - // Derive from path if not provided in request - parts := strings.Split(entity.Path, ".") - if len(parts) > 1 { - parentPath := strings.Join(parts[:len(parts)-1], ".") - if parent, err := s.departmentRepo.GetByPath(ctx, parentPath); err == nil { - parentID = &parent.ID - parentName = &parent.Name - } - } - } - - return &contract.GetDepartmentResponse{ - ID: entity.ID, - Name: entity.Name, - Code: entity.Code, - Path: entity.Path, - ParentID: parentID, - ParentName: parentName, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, - }, nil -} - -func (s *MasterServiceImpl) DeleteDepartment(ctx context.Context, id uuid.UUID) error { - return s.departmentRepo.Delete(ctx, id) -} - -func (s *MasterServiceImpl) GetOrganizationalChartByID(ctx context.Context, departmentID uuid.UUID) (*contract.OrganizationalChartResponse, error) { - // First get the department to find its path - department, err := s.departmentRepo.Get(ctx, departmentID) - if err != nil { - return nil, err - } - - // Now get the organizational chart starting from this department's path - return s.GetOrganizationalChart(ctx, department.Path) -} - -func (s *MasterServiceImpl) GetOrganizationalChart(ctx context.Context, rootPath string) (*contract.OrganizationalChartResponse, error) { - var departments []entities.Department - var err error - - // Get config values - parentPath := s.config.Department.ParentPath - excludedPaths := s.config.Department.ExcludedPaths - - if rootPath == "" { - // Get all departments with parent filter - departments, err = s.departmentRepo.GetAllWithParentFilter(ctx, parentPath, excludedPaths) - } else { - // Get departments under specific path - departments, err = s.departmentRepo.GetByPathPrefix(ctx, rootPath) - // Filter out excluded paths manually for specific path queries - filteredDepts := make([]entities.Department, 0) - for _, dept := range departments { - excluded := false - for _, excludedPath := range excludedPaths { - if strings.Contains(dept.Path, excludedPath) { - excluded = true - break - } - } - if !excluded { - filteredDepts = append(filteredDepts, dept) - } - } - departments = filteredDepts - } - - if err != nil { - return nil, err - } - - // Build the tree structure - nodeMap := make(map[string]*contract.DepartmentNode) - roots := make([]*contract.DepartmentNode, 0) - - // Calculate base level offset based on parent path - baseLevelOffset := 0 - if parentPath != "" { - baseLevelOffset = len(strings.Split(parentPath, ".")) - 1 - } - - // First pass: create all nodes including missing parents - for _, dept := range departments { - pathParts := strings.Split(dept.Path, ".") - - // Create any missing parent nodes - for i := 1; i <= len(pathParts); i++ { - currentPath := strings.Join(pathParts[:i], ".") - if _, exists := nodeMap[currentPath]; !exists { - // Calculate level for this path - adjustedLevel := i - baseLevelOffset - if adjustedLevel < 1 { - adjustedLevel = 1 - } - - // Create node (placeholder for missing parents, real data for existing) - var node *contract.DepartmentNode - if currentPath == dept.Path { - // This is the actual department - node = &contract.DepartmentNode{ - ID: dept.ID, - Name: dept.Name, - Code: dept.Code, - Path: dept.Path, - Level: adjustedLevel, - Children: make([]*contract.DepartmentNode, 0), - } - } else { - // This is a missing parent - create placeholder - // Extract the last segment as the name - lastSegment := pathParts[i-1] - node = &contract.DepartmentNode{ - ID: uuid.Nil, // Use nil UUID for placeholder - Name: strings.ToUpper(strings.ReplaceAll(lastSegment, "_", " ")), - Code: lastSegment, - Path: currentPath, - Level: adjustedLevel, - Children: make([]*contract.DepartmentNode, 0), - } - } - nodeMap[currentPath] = node - } - } - } - - // Second pass: build the tree relationships - // Only process nodes that actually exist in the database (not placeholders) - processedPaths := make(map[string]bool) - for _, dept := range departments { - if processedPaths[dept.Path] { - continue - } - processedPaths[dept.Path] = true - - node := nodeMap[dept.Path] - pathParts := strings.Split(dept.Path, ".") - - // Check if this should be a root node - isRoot := false - if rootPath != "" && dept.Path == rootPath { - // Explicitly requested root - isRoot = true - } else if rootPath == "" && parentPath != "" && dept.Path == parentPath { - // The configured parent path is the root when showing all - isRoot = true - } else if len(pathParts) == 1 { - // Single segment path - isRoot = true - } else { - // Find parent path - parentPathStr := strings.Join(pathParts[:len(pathParts)-1], ".") - if parent, exists := nodeMap[parentPathStr]; exists { - // Check if this child is already added - alreadyAdded := false - for _, child := range parent.Children { - if child.Path == node.Path { - alreadyAdded = true - break - } - } - if !alreadyAdded { - parent.Children = append(parent.Children, node) - } - } else { - // Parent doesn't exist - this is an orphaned node - // Only include it as a root if it's a direct child of the parent path - if parentPath != "" { - // Check if this is a direct child of the configured parent - expectedParent := parentPath - actualParent := strings.Join(pathParts[:len(pathParts)-1], ".") - if actualParent != expectedParent { - // This is an orphaned node - skip it - continue - } - } - isRoot = true - } - } - - if isRoot { - // Check for duplicates in roots - alreadyInRoots := false - for _, r := range roots { - if r.Path == node.Path { - alreadyInRoots = true - break - } - } - if !alreadyInRoots { - roots = append(roots, node) - } - } - } - - // Sort children at each level - var sortChildren func([]*contract.DepartmentNode) - sortChildren = func(nodes []*contract.DepartmentNode) { - for _, node := range nodes { - if len(node.Children) > 0 { - // Sort children by name - sort.Slice(node.Children, func(i, j int) bool { - return node.Children[i].Name < node.Children[j].Name - }) - sortChildren(node.Children) - } - } - } - - // Sort root nodes - sort.Slice(roots, func(i, j int) bool { - return roots[i].Name < roots[j].Name - }) - sortChildren(roots) - - return &contract.OrganizationalChartResponse{ - Chart: roots, - TotalNodes: len(departments), - }, nil -} - -func (s *MasterServiceImpl) ListDepartments(ctx context.Context, req *contract.ListDepartmentsRequest) (*contract.ListDepartmentsResponse, error) { - // Set default values if not provided - page := req.Page - if page < 1 { - page = 1 - } - - limit := req.Limit - if limit < 1 { - limit = 10 - } - if limit > 100 { - limit = 100 // Max limit to prevent performance issues - } - - offset := (page - 1) * limit - - // Use filtered list with parent path from config - parentPath := s.config.Department.ParentPath - excludedPaths := s.config.Department.ExcludedPaths - - list, total, err := s.departmentRepo.ListWithParentFilter(ctx, req.Search, limit, offset, parentPath, excludedPaths) - if err != nil { - return nil, err - } - - return &contract.ListDepartmentsResponse{ - Departments: transformer.DepartmentsToContract(list), - Total: total, - Page: page, - Limit: limit, - }, nil -} diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go deleted file mode 100644 index e40c059..0000000 --- a/internal/service/notification_service.go +++ /dev/null @@ -1,385 +0,0 @@ -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/onlyoffice_service.go b/internal/service/onlyoffice_service.go deleted file mode 100644 index 3ad2376..0000000 --- a/internal/service/onlyoffice_service.go +++ /dev/null @@ -1,708 +0,0 @@ -package service - -import ( - "context" - "crypto/md5" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "eslogad-be/config" - "eslogad-be/internal/appcontext" - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" - "eslogad-be/internal/processor" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" - "gorm.io/gorm" -) - -type OnlyOfficeService interface { - ProcessCallback(ctx context.Context, documentKey string, req *contract.OnlyOfficeCallbackRequest) (*contract.OnlyOfficeCallbackResponse, error) - GetEditorConfig(ctx context.Context, req *contract.GetEditorConfigRequest) (*contract.GetEditorConfigResponse, error) - LockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error - UnlockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error - GetDocumentSession(ctx context.Context, documentKey string) (*contract.DocumentSession, error) - GetOnlyOfficeConfig(ctx context.Context) (*contract.OnlyOfficeConfigInfo, error) -} - -type OnlyOfficeServiceImpl struct { - processor processor.OnlyOfficeProcessor - documentBaseURL string - callbackBaseURL string - serverURL string - jwtSecret string - config *config.OnlyOffice - db *gorm.DB - fileStorage FileStorage - docBucket string -} - -func NewOnlyOfficeService(processor processor.OnlyOfficeProcessor, cfg *config.OnlyOffice, db *gorm.DB, fileStorage FileStorage) *OnlyOfficeServiceImpl { - return &OnlyOfficeServiceImpl{ - processor: processor, - documentBaseURL: getEnvOrDefault("DOCUMENT_BASE_URL", "https://noken-log-api.tni-ad.mil.id/api/v1/files"), - callbackBaseURL: getEnvOrDefault("CALLBACK_BASE_URL", "https://noken-log-api.tni-ad.mil.id/api/v1/onlyoffice/callback"), - serverURL: cfg.URL, - jwtSecret: cfg.Token, - config: cfg, - db: db, - fileStorage: fileStorage, - docBucket: "documents", // Use the same bucket as document uploads - } -} - -func getEnvOrDefault(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value - } - return defaultValue -} - -func (s *OnlyOfficeServiceImpl) ProcessCallback(ctx context.Context, documentKey string, req *contract.OnlyOfficeCallbackRequest) (*contract.OnlyOfficeCallbackResponse, error) { - // Verify JWT token if provided and secret is configured - if req.Token != "" && s.jwtSecret != "" { - claims, err := s.verifyJWT(req.Token) - if err != nil { - // Log the error but continue processing - // OnlyOffice may not always send valid tokens - fmt.Printf("JWT verification failed: %v\n", err) - } else if claims != nil { - // Extract data from JWT claims if needed - if key, ok := claims["key"].(string); ok && key != documentKey { - return &contract.OnlyOfficeCallbackResponse{Error: 1}, fmt.Errorf("document key mismatch in JWT") - } - } - } - - session, err := s.processor.GetDocumentSessionByKey(ctx, documentKey) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return &contract.OnlyOfficeCallbackResponse{Error: 1}, nil // Document key not found - } - return &contract.OnlyOfficeCallbackResponse{Error: 3}, err // Internal server error - } - - // Process based on status - switch req.Status { - case contract.OnlyOfficeStatusEditing: - // Document is being edited - err = s.handleEditingStatus(ctx, session, req) - - case contract.OnlyOfficeStatusReady: - // Document is ready for saving - err = s.handleReadyStatus(ctx, session, req) - - case contract.OnlyOfficeStatusSaveError: - // Document saving error - err = s.handleSaveError(ctx, session, req) - - case contract.OnlyOfficeStatusClosed: - // Document closed with no changes - err = s.handleClosedStatus(ctx, session, req) - - case contract.OnlyOfficeStatusForceSave: - // Force save during editing - err = s.handleForceSave(ctx, session, req) - - case contract.OnlyOfficeStatusForceSaveError: - // Force save error - err = s.handleForceSaveError(ctx, session, req) - - default: - return &contract.OnlyOfficeCallbackResponse{Error: 3}, fmt.Errorf("unknown status: %d", req.Status) - } - - if err != nil { - return &contract.OnlyOfficeCallbackResponse{Error: 3}, err - } - - return &contract.OnlyOfficeCallbackResponse{Error: 0}, nil -} - -// handleEditingStatus handles when document is being edited -func (s *OnlyOfficeServiceImpl) handleEditingStatus(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error { - // Update session status - session.Status = req.Status - - // Lock document if not already locked - if !session.IsLocked && len(req.Users) > 0 { - userID := getOnlyOfficeUserIDFromContext(ctx) - session.IsLocked = true - session.LockedBy = &userID - now := time.Now() - session.LockedAt = &now - } - - return s.processor.UpdateDocumentSession(ctx, session) -} - -// handleReadyStatus handles when document is ready for saving -func (s *OnlyOfficeServiceImpl) handleReadyStatus(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error { - if req.URL == "" { - return errors.New("document URL is required for saving") - } - - // Download the document - documentData, err := s.downloadDocument(req.URL) - if err != nil { - return fmt.Errorf("failed to download document: %w", err) - } - - // Use session UserID as SavedBy since callbacks don't have user context - savedBy := session.UserID - if savedBy == uuid.Nil { - // Fallback to getting from context if available - if userID := getOnlyOfficeUserIDFromContext(ctx); userID != uuid.Nil { - savedBy = userID - } - } - - // Save new version - version := &entities.DocumentVersion{ - DocumentID: session.DocumentID, - Version: session.Version + 1, - FileSize: int64(len(documentData)), - SavedBy: savedBy, - SavedAt: time.Now(), - IsActive: true, - } - - // For now, default to outgoing_attachment - // In production, this should be stored in the session or document metadata - documentType := "outgoing_attachment" - - // Generate new file path and save - fileName := fmt.Sprintf("v%d_%s_%s.docx", version.Version, time.Now().Format("20060102150405"), session.DocumentKey) - filePath, err := s.saveDocumentFile(ctx, documentData, session.DocumentID, fileName, documentType) - if err != nil { - return fmt.Errorf("failed to save document file: %w", err) - } - version.FileURL = filePath - - // Save changes URL if provided - if req.ChangesURL != "" { - version.ChangesURL = &req.ChangesURL - } - - // Create new version - err = s.processor.CreateDocumentVersion(ctx, version) - if err != nil { - return fmt.Errorf("failed to create document version: %w", err) - } - - // Update session - session.Status = req.Status - session.Version = version.Version - now := time.Now() - session.LastSavedAt = &now - session.IsLocked = false - session.LockedBy = nil - session.LockedAt = nil - - // Update the original document reference with new URL - err = s.processor.UpdateDocumentURL(ctx, session.DocumentID, version.FileURL) - if err != nil { - return fmt.Errorf("failed to update document URL: %w", err) - } - - return s.processor.UpdateDocumentSession(ctx, session) -} - -// handleSaveError handles document save errors -func (s *OnlyOfficeServiceImpl) handleSaveError(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error { - // Log the error - s.processor.LogDocumentError(ctx, session.DocumentID, "Save error occurred", req) - - // Update session status - session.Status = req.Status - return s.processor.UpdateDocumentSession(ctx, session) -} - -// handleClosedStatus handles when document is closed without changes -func (s *OnlyOfficeServiceImpl) handleClosedStatus(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error { - // Unlock document - session.Status = req.Status - session.IsLocked = false - session.LockedBy = nil - session.LockedAt = nil - - return s.processor.UpdateDocumentSession(ctx, session) -} - -func (s *OnlyOfficeServiceImpl) handleForceSave(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error { - if req.URL == "" { - return errors.New("document URL is required for force save") - } - - documentData, err := s.downloadDocument(req.URL) - if err != nil { - return fmt.Errorf("failed to download document: %w", err) - } - - savedBy := session.UserID - if savedBy == uuid.Nil { - if userID := getOnlyOfficeUserIDFromContext(ctx); userID != uuid.Nil { - savedBy = userID - } - } - - version := &entities.DocumentVersion{ - DocumentID: session.DocumentID, - Version: session.Version + 1, - FileSize: int64(len(documentData)), - SavedBy: savedBy, - SavedAt: time.Now(), - IsActive: false, - Comments: stringPtr("Auto-save during editing"), - } - - // For now, default to outgoing_attachment - // In production, this should be stored in the session or document metadata - documentType := "outgoing_attachment" - - fileName := fmt.Sprintf("autosave_v%d_%s_%s.docx", version.Version, time.Now().Format("20060102150405"), session.DocumentKey) - filePath, err := s.saveDocumentFile(ctx, documentData, session.DocumentID, fileName, documentType) - if err != nil { - return fmt.Errorf("failed to save document file: %w", err) - } - version.FileURL = filePath - - err = s.processor.CreateDocumentVersion(ctx, version) - if err != nil { - return fmt.Errorf("failed to create document version: %w", err) - } - - now := time.Now() - session.LastSavedAt = &now - session.Version = version.Version - - return s.processor.UpdateDocumentSession(ctx, session) -} - -// handleForceSaveError handles force save errors -func (s *OnlyOfficeServiceImpl) handleForceSaveError(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error { - // Log the error - s.processor.LogDocumentError(ctx, session.DocumentID, "Force save error occurred", req) - - // Update session status - session.Status = req.Status - return s.processor.UpdateDocumentSession(ctx, session) -} - -// downloadDocument downloads document from OnlyOffice -func (s *OnlyOfficeServiceImpl) downloadDocument(url string) ([]byte, error) { - resp, err := http.Get(url) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to download document: status %d", resp.StatusCode) - } - - return io.ReadAll(resp.Body) -} - -// saveDocumentFile saves document file to S3 storage -func (s *OnlyOfficeServiceImpl) saveDocumentFile(ctx context.Context, data []byte, documentID uuid.UUID, fileName string, documentType string) (string, error) { - // Ensure bucket exists - if err := s.fileStorage.EnsureBucket(ctx, s.docBucket); err != nil { - return "", fmt.Errorf("failed to ensure bucket: %w", err) - } - - // Create S3 key with date structure - dateDir := time.Now().Format("2006/01/02") - key := fmt.Sprintf("onlyoffice/%s/%s/%s", documentID.String(), dateDir, fileName) - - // Detect content type from file extension - contentType := "application/octet-stream" - ext := filepath.Ext(fileName) - switch strings.ToLower(ext) { - case ".docx": - contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - case ".doc": - contentType = "application/msword" - case ".xlsx": - contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - case ".xls": - contentType = "application/vnd.ms-excel" - case ".pptx": - contentType = "application/vnd.openxmlformats-officedocument.presentationml.presentation" - case ".ppt": - contentType = "application/vnd.ms-powerpoint" - case ".pdf": - contentType = "application/pdf" - } - - // Upload to S3 - url, err := s.fileStorage.Upload(ctx, s.docBucket, key, data, contentType) - if err != nil { - return "", fmt.Errorf("failed to upload to S3: %w", err) - } - - // Now update the attachment URL in the database - if err := s.updateAttachmentURL(ctx, documentID, url, documentType); err != nil { - // Log error but don't fail - the document is already saved - fmt.Printf("Warning: Failed to update attachment URL: %v\n", err) - } - - return url, nil -} - -// updateAttachmentURL updates the file_url in the appropriate attachment table -func (s *OnlyOfficeServiceImpl) updateAttachmentURL(ctx context.Context, attachmentID uuid.UUID, newURL string, documentType string) error { - switch documentType { - case "letter_outgoing_attachment", "outgoing_attachment": - return s.db.WithContext(ctx). - Table("letter_outgoing_attachments"). - Where("id = ?", attachmentID). - Update("file_url", newURL).Error - - case "letter_incoming_attachment", "incoming_attachment": - return s.db.WithContext(ctx). - Table("letter_incoming_attachments"). - Where("id = ?", attachmentID). - Update("file_url", newURL).Error - - default: - return fmt.Errorf("unsupported document type for URL update: %s", documentType) - } -} - -// getDocumentFromAttachment retrieves document details directly from attachment tables -func (s *OnlyOfficeServiceImpl) getDocumentFromAttachment(ctx context.Context, documentID uuid.UUID, documentType string) (*processor.DocumentDetails, error) { - var fileName, fileURL, fileType string - var fileSize int64 - - switch documentType { - case "letter_outgoing_attachment", "outgoing_attachment": - var attachment struct { - FileName string `gorm:"column:file_name"` - FileURL string `gorm:"column:file_url"` - FileType string `gorm:"column:file_type"` - } - - err := s.db.WithContext(ctx). - Table("letter_outgoing_attachments"). - Where("id = ?", documentID). - Select("file_name, file_url, file_type"). - First(&attachment).Error - - if err != nil { - return nil, fmt.Errorf("failed to get outgoing attachment: %w", err) - } - - fileName = attachment.FileName - fileURL = attachment.FileURL - fileType = attachment.FileType - - case "letter_incoming_attachment", "incoming_attachment": - var attachment struct { - FileName string `gorm:"column:file_name"` - FileURL string `gorm:"column:file_url"` - FileType string `gorm:"column:file_type"` - } - - err := s.db.WithContext(ctx). - Table("letter_incoming_attachments"). - Where("id = ?", documentID). - Select("file_name, file_url, file_type"). - First(&attachment).Error - - if err != nil { - return nil, fmt.Errorf("failed to get incoming attachment: %w", err) - } - - fileName = attachment.FileName - fileURL = attachment.FileURL - fileType = attachment.FileType - - default: - return nil, fmt.Errorf("unsupported document type: %s", documentType) - } - - return &processor.DocumentDetails{ - DocumentID: documentID, - FileName: fileName, - FileType: fileType, - FileURL: fileURL, - FileSize: fileSize, - DocumentType: documentType, - ReferenceID: documentID, - }, nil -} - -// GetEditorConfig generates OnlyOffice editor configuration -func (s *OnlyOfficeServiceImpl) GetEditorConfig(ctx context.Context, req *contract.GetEditorConfigRequest) (*contract.GetEditorConfigResponse, error) { - userCtx := appcontext.FromGinContext(ctx) - if userCtx == nil { - return nil, errors.New("user context not found") - } - - session, err := s.processor.GetOrCreateDocumentSession(ctx, req.DocumentID, userCtx.UserID) - if err != nil { - return nil, fmt.Errorf("failed to get document session: %w", err) - } - - // Get document details directly from attachment tables - document, err := s.getDocumentFromAttachment(ctx, req.DocumentID, req.DocumentType) - if err != nil { - return nil, fmt.Errorf("failed to get document details: %w", err) - } - - documentKey := session.DocumentKey - - fileExt := s.getFileExtension(document.FileName) - if fileExt == "" || fileExt == strings.ToLower(document.FileName) { - fileExt = s.getFileExtension(document.FileType) - } - - ooType := "desktop" - if req.DocumentType == "incoming_attachment" { - ooType = "embedded" - } - - config := &contract.OnlyOfficeConfigRequest{ - Document: &contract.OnlyOfficeDocument{ - FileType: fileExt, - Key: documentKey, - Title: document.FileName, - URL: document.FileURL, - Permissions: &contract.OnlyOfficePermissions{ - Comment: true, - Download: true, - Edit: req.Mode == "edit", - FillForms: true, - Print: true, - Review: req.Mode == "edit", - }, - Info: &contract.OnlyOfficeDocumentInfo{ - Owner: fmt.Sprintf("User-%s", userCtx.UserID.String()[:8]), - Uploaded: time.Now().Format("2006-01-02 15:04:05"), - }, - }, - DocumentType: s.getDocumentType(fileExt), // Convert file extension to document type - EditorConfig: &contract.OnlyOfficeEditorConfig{ - CallbackURL: fmt.Sprintf("%s/%s", s.callbackBaseURL, documentKey), - Lang: "en", - Mode: req.Mode, - User: &contract.OnlyOfficeUserConfig{ - ID: userCtx.UserID.String(), - Name: userCtx.UserName, - }, - Customization: &contract.OnlyOfficeCustomization{ - Autosave: true, - Comments: true, - CompactHeader: false, - ForceSave: true, - Zoom: 100, - }, - }, - Type: ooType, // Can be desktop, mobile, or embedded - } - - if s.jwtSecret != "" { - token, err := s.generateJWT(config) - if err != nil { - return nil, fmt.Errorf("failed to generate JWT: %w", err) - } - config.Token = token - } - - return &contract.GetEditorConfigResponse{ - DocumentServerURL: s.serverURL, - Config: config, - }, nil -} - -// LockDocument locks a document for editing -func (s *OnlyOfficeServiceImpl) LockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error { - return s.processor.LockDocument(ctx, documentID, userID) -} - -// UnlockDocument unlocks a document -func (s *OnlyOfficeServiceImpl) UnlockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error { - return s.processor.UnlockDocument(ctx, documentID, userID) -} - -// GetDocumentSession gets document session by key -func (s *OnlyOfficeServiceImpl) GetDocumentSession(ctx context.Context, documentKey string) (*contract.DocumentSession, error) { - session, err := s.processor.GetDocumentSessionByKey(ctx, documentKey) - if err != nil { - return nil, err - } - - return &contract.DocumentSession{ - ID: session.ID, - DocumentID: session.DocumentID, - DocumentKey: session.DocumentKey, - UserID: session.UserID, - Status: session.Status, - IsLocked: session.IsLocked, - LockedBy: session.LockedBy, - LockedAt: session.LockedAt, - LastSavedAt: session.LastSavedAt, - Version: session.Version, - CreatedAt: session.CreatedAt, - UpdatedAt: session.UpdatedAt, - }, nil -} - -// generateDocumentKey generates a unique key for OnlyOffice -func (s *OnlyOfficeServiceImpl) generateDocumentKey(documentID uuid.UUID, version int) string { - // Use nanoseconds and random bytes for uniqueness - randomBytes := make([]byte, 8) - rand.Read(randomBytes) - data := fmt.Sprintf("%s_%d_%d_%s", documentID.String(), version, time.Now().UnixNano(), hex.EncodeToString(randomBytes)) - hash := md5.Sum([]byte(data)) - return hex.EncodeToString(hash[:]) -} - -// getFileExtension extracts the file extension from file type or filename -func (s *OnlyOfficeServiceImpl) getFileExtension(fileType string) string { - // Remove any leading dot - fileType = strings.TrimPrefix(fileType, ".") - - // If fileType contains a dot, extract the extension after the last dot - if strings.Contains(fileType, ".") { - parts := strings.Split(fileType, ".") - if len(parts) > 1 { - return strings.ToLower(parts[len(parts)-1]) - } - } - - // Otherwise, return as is (assuming it's already an extension like "docx", "xlsx", etc.) - return strings.ToLower(fileType) -} - -// getDocumentType determines OnlyOffice document type from file extension -func (s *OnlyOfficeServiceImpl) getDocumentType(fileType string) string { - fileType = strings.ToLower(fileType) - - // Remove dot if present - fileType = strings.TrimPrefix(fileType, ".") - - // Text documents - if fileType == "doc" || fileType == "docx" || fileType == "docm" || - fileType == "dot" || fileType == "dotx" || fileType == "dotm" || - fileType == "odt" || fileType == "fodt" || fileType == "ott" || - fileType == "rtf" || fileType == "txt" || fileType == "html" || - fileType == "htm" || fileType == "mht" || fileType == "pdf" || - fileType == "djvu" || fileType == "fb2" || fileType == "epub" || - fileType == "xps" { - return "word" - } - - // Spreadsheets - if fileType == "xls" || fileType == "xlsx" || fileType == "xlsm" || - fileType == "xlt" || fileType == "xltx" || fileType == "xltm" || - fileType == "ods" || fileType == "fods" || fileType == "ots" || - fileType == "csv" { - return "cell" - } - - // Presentations - if fileType == "pps" || fileType == "ppsx" || fileType == "ppsm" || - fileType == "ppt" || fileType == "pptx" || fileType == "pptm" || - fileType == "pot" || fileType == "potx" || fileType == "potm" || - fileType == "odp" || fileType == "fodp" || fileType == "otp" { - return "presentation" - } - - // Default to text - return "slide" -} - -// generateJWT generates JWT token for OnlyOffice -func (s *OnlyOfficeServiceImpl) generateJWT(config *contract.OnlyOfficeConfigRequest) (string, error) { - // OnlyOffice expects the entire config to be in the JWT payload - payload := make(map[string]interface{}) - - // Convert the config struct to a map for JWT payload - configJSON, err := json.Marshal(config) - if err != nil { - return "", fmt.Errorf("failed to marshal config: %w", err) - } - - var configMap map[string]interface{} - if err := json.Unmarshal(configJSON, &configMap); err != nil { - return "", fmt.Errorf("failed to unmarshal config to map: %w", err) - } - - // Add all config fields to the payload - for key, value := range configMap { - payload[key] = value - } - - // Create the token with the payload - token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(payload)) - - // Sign the token with the secret - tokenString, err := token.SignedString([]byte(s.jwtSecret)) - if err != nil { - return "", fmt.Errorf("failed to sign JWT token: %w", err) - } - - return tokenString, nil -} - -func getOnlyOfficeUserIDFromContext(ctx context.Context) uuid.UUID { - userCtx := appcontext.FromGinContext(ctx) - if userCtx != nil { - return userCtx.UserID - } - return uuid.Nil -} - -func stringPtr(s string) *string { - return &s -} - -// GetOnlyOfficeConfig returns the OnlyOffice configuration -func (s *OnlyOfficeServiceImpl) GetOnlyOfficeConfig(ctx context.Context) (*contract.OnlyOfficeConfigInfo, error) { - return &contract.OnlyOfficeConfigInfo{ - URL: s.config.URL, - Token: s.config.Token, - }, nil -} - -// verifyJWT verifies JWT token from OnlyOffice -func (s *OnlyOfficeServiceImpl) verifyJWT(tokenString string) (jwt.MapClaims, error) { - if s.jwtSecret == "" { - // If no secret is configured, skip verification - return nil, nil - } - - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - // Validate the signing method - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(s.jwtSecret), nil - }) - - if err != nil { - return nil, fmt.Errorf("failed to parse JWT: %w", err) - } - - if !token.Valid { - return nil, errors.New("invalid JWT token") - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return nil, errors.New("failed to parse JWT claims") - } - - return claims, nil -} diff --git a/internal/service/rbac_service.go b/internal/service/rbac_service.go deleted file mode 100644 index 143f338..0000000 --- a/internal/service/rbac_service.go +++ /dev/null @@ -1,286 +0,0 @@ -package service - -import ( - "context" - - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" - "eslogad-be/internal/repository" - "eslogad-be/internal/transformer" - - "github.com/google/uuid" -) - -type RBACServiceImpl struct { - repo *repository.RBACRepository -} - -func NewRBACService(repo *repository.RBACRepository) *RBACServiceImpl { - return &RBACServiceImpl{repo: repo} -} - -// Permissions -func (s *RBACServiceImpl) CreatePermission(ctx context.Context, req *contract.CreatePermissionRequest) (*contract.PermissionResponse, error) { - p := &entities.Permission{Code: req.Code} - if req.Description != nil { - p.Description = *req.Description - } - if err := s.repo.CreatePermission(ctx, p); err != nil { - return nil, err - } - return &contract.PermissionResponse{ - ID: p.ID, - Code: p.Code, - Action: p.Action, - Description: &p.Description, - }, nil -} -func (s *RBACServiceImpl) UpdatePermission(ctx context.Context, id uuid.UUID, req *contract.UpdatePermissionRequest) (*contract.PermissionResponse, error) { - p := &entities.Permission{ID: id} - if req.Code != nil { - p.Code = *req.Code - } - if req.Description != nil { - p.Description = *req.Description - } - if err := s.repo.UpdatePermission(ctx, p); err != nil { - return nil, err - } - // fetch full row - perms, err := s.repo.ListPermissions(ctx) - if err != nil { - return nil, err - } - for _, x := range perms { - if x.ID == id { - return &contract.PermissionResponse{ - ID: x.ID, - Code: x.Code, - Action: x.Action, - Description: &x.Description, - }, nil - } - } - return nil, nil -} -func (s *RBACServiceImpl) DeletePermission(ctx context.Context, id uuid.UUID) error { - return s.repo.DeletePermission(ctx, id) -} -func (s *RBACServiceImpl) ListPermissions(ctx context.Context) (*contract.ListPermissionsResponse, error) { - perms, err := s.repo.ListPermissions(ctx) - if err != nil { - return nil, err - } - return &contract.ListPermissionsResponse{Permissions: transformer.PermissionsToContract(perms)}, nil -} - -// Roles -func (s *RBACServiceImpl) CreateRole(ctx context.Context, req *contract.CreateRoleRequest) (*contract.RoleWithPermissionsResponse, error) { - role := &entities.Role{Name: req.Name, Code: req.Code} - if req.Description != nil { - role.Description = *req.Description - } - if err := s.repo.CreateRole(ctx, role); err != nil { - return nil, err - } - if len(req.PermissionCodes) > 0 { - _ = s.repo.SetRolePermissionsByCodes(ctx, role.ID, req.PermissionCodes) - } - perms, _ := s.repo.GetPermissionsByRoleID(ctx, role.ID) - resp := transformer.RoleWithPermissionsToContract(*role, perms) - return &resp, nil -} -func (s *RBACServiceImpl) UpdateRole(ctx context.Context, id uuid.UUID, req *contract.UpdateRoleRequest) (*contract.RoleWithPermissionsResponse, error) { - role := &entities.Role{ID: id} - if req.Name != nil { - role.Name = *req.Name - } - if req.Code != nil { - role.Code = *req.Code - } - if req.Description != nil { - role.Description = *req.Description - } - if err := s.repo.UpdateRole(ctx, role); err != nil { - return nil, err - } - if req.PermissionCodes != nil { - _ = s.repo.SetRolePermissionsByCodes(ctx, id, *req.PermissionCodes) - } - perms, _ := s.repo.GetPermissionsByRoleID(ctx, id) - // fetch updated role - roles, err := s.repo.ListRoles(ctx) - if err != nil { - return nil, err - } - for _, r := range roles { - if r.ID == id { - resp := transformer.RoleWithPermissionsToContract(r, perms) - return &resp, nil - } - } - return nil, nil -} -func (s *RBACServiceImpl) DeleteRole(ctx context.Context, id uuid.UUID) error { - return s.repo.DeleteRole(ctx, id) -} -func (s *RBACServiceImpl) ListRoles(ctx context.Context) (*contract.ListRolesResponse, error) { - roles, err := s.repo.ListRoles(ctx) - if err != nil { - return nil, err - } - out := make([]contract.RoleWithPermissionsResponse, 0, len(roles)) - for _, r := range roles { - perms, _ := s.repo.GetPermissionsByRoleID(ctx, r.ID) - out = append(out, transformer.RoleWithPermissionsToContract(r, perms)) - } - return &contract.ListRolesResponse{Roles: out}, nil -} - -// New methods for the required API endpoints -func (s *RBACServiceImpl) GetPermissionsGrouped(ctx context.Context) (*contract.PermissionsGroupedResponse, error) { - modules, err := s.repo.ListModules(ctx) - if err != nil { - return nil, err - } - - result := make([]contract.ModuleWithPermissionsResponse, 0, len(modules)) - for _, module := range modules { - perms, err := s.repo.ListPermissions(ctx) - if err != nil { - return nil, err - } - - modulePerms := make([]contract.PermissionResponse, 0) - for _, perm := range perms { - if perm.ModuleID != nil && *perm.ModuleID == module.ID { - modulePerms = append(modulePerms, contract.PermissionResponse{ - ID: perm.ID, - Code: perm.Code, - Action: perm.Action, - Description: &perm.Description, - }) - } - } - - result = append(result, contract.ModuleWithPermissionsResponse{ - Module: contract.ModuleResponse{ - ID: module.ID, - Name: module.Name, - Code: module.Code, - }, - Permissions: modulePerms, - }) - } - - return &contract.PermissionsGroupedResponse{Data: result}, nil -} - -func (s *RBACServiceImpl) CreateOrUpdateRole(ctx context.Context, req *contract.CreateOrUpdateRoleRequest) (*contract.RoleDetailResponse, error) { - // Check if role exists - existingRole, _ := s.repo.GetRoleByCode(ctx, req.Code) - - var role *entities.Role - if existingRole != nil { - // Update existing role - role = existingRole - role.Name = req.Name - role.Description = req.Description - if err := s.repo.UpdateRole(ctx, role); err != nil { - return nil, err - } - } else { - // Create new role - role = &entities.Role{ - Name: req.Name, - Code: req.Code, - Description: req.Description, - } - if err := s.repo.CreateRole(ctx, role); err != nil { - return nil, err - } - } - - // Set permissions based on module and actions - permissionIDs := make([]uuid.UUID, 0) - for _, modPerm := range req.Permissions { - _, err := s.repo.GetModuleByCode(ctx, modPerm.Module) - if err != nil { - continue // Skip if module not found - } - - for _, action := range modPerm.Actions { - permCode := modPerm.Module + "_" + action - perm, err := s.repo.GetPermissionByCode(ctx, permCode) - if err == nil && perm != nil { - permissionIDs = append(permissionIDs, perm.ID) - } - } - } - - if err := s.repo.SetRolePermissionsByIDs(ctx, role.ID, permissionIDs); err != nil { - return nil, err - } - - // Build response - return s.GetRoleDetail(ctx, role.ID) -} - -func (s *RBACServiceImpl) GetRoleDetail(ctx context.Context, roleID uuid.UUID) (*contract.RoleDetailResponse, error) { - role, err := s.repo.GetRoleByID(ctx, roleID) - if err != nil { - return nil, err - } - - permissions, err := s.repo.GetPermissionsByRoleID(ctx, roleID) - if err != nil { - return nil, err - } - - // Group permissions by module - moduleMap := make(map[uuid.UUID]*contract.RolePermissionModuleResponse) - - for _, perm := range permissions { - if perm.ModuleID == nil { - continue - } - - if _, exists := moduleMap[*perm.ModuleID]; !exists { - if perm.Module != nil { - moduleMap[*perm.ModuleID] = &contract.RolePermissionModuleResponse{ - Module: contract.ModuleResponse{ - ID: perm.Module.ID, - Name: perm.Module.Name, - Code: perm.Module.Code, - }, - Actions: []contract.PermissionActionResponse{}, - } - } - } - - if modResp, exists := moduleMap[*perm.ModuleID]; exists { - modResp.Actions = append(modResp.Actions, contract.PermissionActionResponse{ - ID: perm.ID, - Action: perm.Action, - Code: perm.Code, - Description: perm.Description, - }) - } - } - - // Convert map to slice - permissionModules := make([]contract.RolePermissionModuleResponse, 0, len(moduleMap)) - for _, modResp := range moduleMap { - permissionModules = append(permissionModules, *modResp) - } - - return &contract.RoleDetailResponse{ - ID: role.ID, - Name: role.Name, - Code: role.Code, - Description: role.Description, - CreatedAt: role.CreatedAt, - UpdatedAt: role.UpdatedAt, - Permissions: permissionModules, - }, nil -} diff --git a/internal/service/repository_attachment_processor.go b/internal/service/repository_attachment_processor.go deleted file mode 100644 index 6f88baa..0000000 --- a/internal/service/repository_attachment_processor.go +++ /dev/null @@ -1,15 +0,0 @@ -package service - -import ( - "context" - "eslogad-be/internal/contract" - - "github.com/google/uuid" -) - -type RepositoryAttachmentProcessor interface { - CreateAttachment(ctx context.Context, req *contract.CreateRepositoryAttachmentRequest) (*contract.RepositoryAttachmentsResponse, error) - DeleteAttachment(ctx context.Context, id uuid.UUID) error - GetById(ctx context.Context, id uuid.UUID) (*contract.RepositoryAttachmentsResponse, error) - ListAttachment(ctx context.Context, search *string, limit, offset int) ([]contract.RepositoryAttachmentsResponse, int, error) -} diff --git a/internal/service/repository_attachment_service.go b/internal/service/repository_attachment_service.go deleted file mode 100644 index 9c96839..0000000 --- a/internal/service/repository_attachment_service.go +++ /dev/null @@ -1,59 +0,0 @@ -package service - -import ( - "context" - "eslogad-be/internal/contract" - "eslogad-be/internal/transformer" - - "github.com/google/uuid" -) - -type RepositoryAttachmentServiceImpl struct { - attachmentProcessor RepositoryAttachmentProcessor -} - -func NewRepositoryAttachmentService(attachmentProcessor RepositoryAttachmentProcessor) *RepositoryAttachmentServiceImpl { - return &RepositoryAttachmentServiceImpl{ - attachmentProcessor: attachmentProcessor, - } -} - -func (s *RepositoryAttachmentServiceImpl) CreateAttachment(ctx context.Context, req *contract.CreateRepositoryAttachmentRequest) (*contract.RepositoryAttachmentsResponse, error) { - return s.attachmentProcessor.CreateAttachment(ctx, req) -} - -func (s *RepositoryAttachmentServiceImpl) DeleteAttachment(ctx context.Context, id uuid.UUID) error { - return s.attachmentProcessor.DeleteAttachment(ctx, id) -} - -func (s *RepositoryAttachmentServiceImpl) GetById(ctx context.Context, id uuid.UUID) (*contract.RepositoryAttachmentsResponse, error) { - return s.attachmentProcessor.GetById(ctx, id) -} - -func (s *RepositoryAttachmentServiceImpl) ListAttachment(ctx context.Context, req *contract.ListRepositoryAttachmentsRequest) (*contract.ListRepositoryAttachmentsResponse, error) { - page := req.Page - if page <= 0 { - page = 1 - } - - limit := req.Limit - if limit <= 0 { - limit = 10 - } - if limit > 100 { - limit = 100 // Max limit to prevent performance issues - } - - offset := (page - 1) * limit - - // Pass calculated offset and limit to processor - attachmentResponses, totalCount, err := s.attachmentProcessor.ListAttachment(ctx, req.Search, limit, offset) - if err != nil { - return nil, err - } - - return &contract.ListRepositoryAttachmentsResponse{ - Attachments: attachmentResponses, - Pagination: transformer.CreatePaginationResponse(totalCount, page, limit), - }, nil -} diff --git a/internal/service/user_processor.go b/internal/service/user_processor.go deleted file mode 100644 index c4f5773..0000000 --- a/internal/service/user_processor.go +++ /dev/null @@ -1,34 +0,0 @@ -package service - -import ( - "context" - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" - - "github.com/google/uuid" -) - -type UserProcessor interface { - UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) - 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 - ChangeUserPassword(ctx context.Context, userID uuid.UUID, req *contract.ChangeUserPasswordRequest) error - - GetUserRoles(ctx context.Context, userID uuid.UUID) ([]contract.RoleResponse, error) - GetUserPermissionCodes(ctx context.Context, userID uuid.UUID) ([]string, error) - GetUserDepartments(ctx context.Context, userID uuid.UUID) ([]contract.DepartmentResponse, error) - - GetUserProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) - UpdateUserProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) - - // New optimized listing - ListUsersWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]contract.UserResponse, int, error) - - // Get active users for mention purposes - GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) -} diff --git a/internal/service/user_service.go b/internal/service/user_service.go index 5199e1e..880b628 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -2,122 +2,92 @@ package service import ( "context" + "errors" - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" - "eslogad-be/internal/transformer" + "go-backend-template/internal/contract" + "go-backend-template/internal/repository" + "go-backend-template/internal/transformer" "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" ) type UserServiceImpl struct { - userProcessor UserProcessor - titleRepo TitleRepository + userRepo *repository.UserRepositoryImpl } -type TitleRepository interface { - ListAll(ctx context.Context) ([]entities.Title, error) -} - -func NewUserService(userProcessor UserProcessor, titleRepo TitleRepository) *UserServiceImpl { +func NewUserService(userRepo *repository.UserRepositoryImpl) *UserServiceImpl { return &UserServiceImpl{ - userProcessor: userProcessor, - titleRepo: titleRepo, + userRepo: userRepo, } } func (s *UserServiceImpl) CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error) { - return s.userProcessor.CreateUser(ctx, req) -} + // Check if user already exists + existingUser, _ := s.userRepo.GetByEmail(ctx, req.Email) + if existingUser != nil { + return nil, errors.New("user with this email already exists") + } -func (s *UserServiceImpl) UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) { - return s.userProcessor.UpdateUser(ctx, id, req) -} + // Hash password + passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } -func (s *UserServiceImpl) DeleteUser(ctx context.Context, id uuid.UUID) error { - return s.userProcessor.DeleteUser(ctx, id) + // Create user entity + user := transformer.CreateUserRequestToEntity(req, string(passwordHash)) + user.ID = uuid.New() + + // Save to database + if err := s.userRepo.Create(ctx, user); err != nil { + return nil, err + } + + return transformer.EntityToContract(user), nil } func (s *UserServiceImpl) GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) { - return s.userProcessor.GetUserByID(ctx, id) -} - -func (s *UserServiceImpl) GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error) { - return s.userProcessor.GetUserByEmail(ctx, email) -} - -func (s *UserServiceImpl) ListUsers(ctx context.Context, req *contract.ListUsersRequest) (*contract.ListUsersResponse, error) { - // Handle pagination parameters in service layer - page := req.Page - if page <= 0 { - page = 1 - } - - limit := req.Limit - if limit <= 0 { - limit = 10 - } - if limit > 100 { - limit = 100 // Max limit to prevent performance issues - } - - offset := (page - 1) * limit - - // Pass calculated offset and limit to processor - userResponses, totalCount, err := s.userProcessor.ListUsersWithFilters(ctx, req.Search, req.RoleCode, req.IsActive, limit, offset) + user, err := s.userRepo.GetByID(ctx, id) if err != nil { return nil, err } - return &contract.ListUsersResponse{ + return transformer.EntityToContract(user), nil +} + +func (s *UserServiceImpl) GetUsers(ctx context.Context, page, limit int) (*contract.PaginatedUserResponse, error) { + users, totalCount, err := s.userRepo.GetAll(ctx, page, limit) + if err != nil { + return nil, err + } + + userResponses := transformer.EntitiesToContracts(users) + pagination := transformer.CreatePaginationResponse(int(totalCount), page, limit) + + return &contract.PaginatedUserResponse{ Users: userResponses, - Pagination: transformer.CreatePaginationResponse(totalCount, page, limit), + Pagination: pagination, }, nil } -func (s *UserServiceImpl) ChangePassword(ctx context.Context, userID uuid.UUID, req *contract.ChangePasswordRequest) error { - return s.userProcessor.ChangePassword(ctx, userID, req) -} - -func (s *UserServiceImpl) ChangeUserPassword(ctx context.Context, userID uuid.UUID, req *contract.ChangeUserPasswordRequest) error { - return s.userProcessor.ChangeUserPassword(ctx, userID, req) -} - -func (s *UserServiceImpl) GetProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) { - prof, err := s.userProcessor.GetUserProfile(ctx, userID) +func (s *UserServiceImpl) UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) { + user, err := s.userRepo.GetByID(ctx, id) if err != nil { return nil, err } - if roles, err := s.userProcessor.GetUserRoles(ctx, userID); err == nil { - prof.Roles = roles - } - return prof, nil -} -func (s *UserServiceImpl) UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) { - return s.userProcessor.UpdateUserProfile(ctx, userID, req) -} + // Update user fields + updatedUser := transformer.UpdateUserEntity(user, req) -func (s *UserServiceImpl) ListTitles(ctx context.Context) (*contract.ListTitlesResponse, error) { - if s.titleRepo == nil { - return &contract.ListTitlesResponse{Titles: []contract.TitleResponse{}}, nil - } - titles, err := s.titleRepo.ListAll(ctx) - if err != nil { + // Save to database + if err := s.userRepo.Update(ctx, updatedUser); err != nil { return nil, err } - return &contract.ListTitlesResponse{Titles: transformer.TitlesToContract(titles)}, nil + + return transformer.EntityToContract(updatedUser), nil } -// GetActiveUsersForMention retrieves active users for mention purposes -func (s *UserServiceImpl) GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) { - // Handle limit in service layer - if limit <= 0 { - limit = 50 // Default limit for mention suggestions - } - if limit > 100 { - limit = 100 // Max limit to prevent performance issues - } - - return s.userProcessor.GetActiveUsersForMention(ctx, search, limit) +func (s *UserServiceImpl) DeleteUser(ctx context.Context, id uuid.UUID) error { + return s.userRepo.Delete(ctx, id) } diff --git a/internal/transformer/common_transformer.go b/internal/transformer/common_transformer.go index 45ee4f7..e7c1ecd 100644 --- a/internal/transformer/common_transformer.go +++ b/internal/transformer/common_transformer.go @@ -1,11 +1,8 @@ package transformer import ( - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" + "go-backend-template/internal/contract" "math" - - "github.com/google/uuid" ) func PaginationToRequest(page, limit int) (int, int) { @@ -35,14 +32,6 @@ func CreatePaginationResponse(totalCount, page, limit int) contract.PaginationRe } } -func CreateListUsersResponse(users []contract.UserResponse, totalCount, page, limit int) *contract.ListUsersResponse { - pagination := CreatePaginationResponse(totalCount, page, limit) - return &contract.ListUsersResponse{ - Users: users, - Pagination: pagination, - } -} - func CreateErrorResponse(message string, code int) *contract.ErrorResponse { return &contract.ErrorResponse{ Error: "error", @@ -66,229 +55,3 @@ func CreateSuccessResponse(message string, data interface{}) *contract.SuccessRe Data: data, } } - -func RolesToContract(roles []entities.Role) []contract.RoleResponse { - if roles == nil { - return nil - } - res := make([]contract.RoleResponse, 0, len(roles)) - for _, r := range roles { - res = append(res, contract.RoleResponse{ID: r.ID, Name: r.Name, Code: r.Code}) - } - return res -} - -func DepartmentsToContract(positions []entities.Department) []contract.DepartmentResponse { - if positions == nil { - return nil - } - res := make([]contract.DepartmentResponse, 0, len(positions)) - for _, p := range positions { - res = append(res, contract.DepartmentResponse{ - ID: p.ID, - Name: p.Name, - Code: p.Code, - Path: p.Path, - }) - } - return res -} - -func DepartmentToContract(p entities.Department) contract.DepartmentResponse { - return contract.DepartmentResponse{ - ID: p.ID, - Name: p.Name, - Code: p.Code, - Path: p.Path, - } -} - -func ProfileEntityToContract(p *entities.UserProfile) *contract.UserProfileResponse { - if p == nil { - return nil - } - return &contract.UserProfileResponse{ - UserID: p.UserID, - FullName: p.FullName, - DisplayName: p.DisplayName, - Phone: p.Phone, - AvatarURL: p.AvatarURL, - JobTitle: p.JobTitle, - EmployeeNo: p.EmployeeNo, - Bio: p.Bio, - Timezone: p.Timezone, - Locale: p.Locale, - Preferences: map[string]interface{}(p.Preferences), - NotificationPrefs: map[string]interface{}(p.NotificationPrefs), - LastSeenAt: p.LastSeenAt, - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, - } -} - -func ProfileUpdateToEntity(userID uuid.UUID, req *contract.UpdateUserProfileRequest, existing *entities.UserProfile) *entities.UserProfile { - prof := &entities.UserProfile{} - if existing != nil { - *prof = *existing - } else { - prof.UserID = userID - } - if req.FullName != nil { - prof.FullName = *req.FullName - } - if req.DisplayName != nil { - prof.DisplayName = req.DisplayName - } - if req.Phone != nil { - prof.Phone = req.Phone - } - if req.AvatarURL != nil { - prof.AvatarURL = req.AvatarURL - } - if req.JobTitle != nil { - prof.JobTitle = req.JobTitle - } - if req.EmployeeNo != nil { - prof.EmployeeNo = req.EmployeeNo - } - if req.Bio != nil { - prof.Bio = req.Bio - } - if req.Timezone != nil { - prof.Timezone = *req.Timezone - } - if req.Locale != nil { - prof.Locale = *req.Locale - } - if req.Preferences != nil { - prof.Preferences = entities.JSONB(*req.Preferences) - } - if req.NotificationPrefs != nil { - prof.NotificationPrefs = entities.JSONB(*req.NotificationPrefs) - } - return prof -} - -func TitlesToContract(titles []entities.Title) []contract.TitleResponse { - if titles == nil { - return nil - } - out := make([]contract.TitleResponse, 0, len(titles)) - for _, t := range titles { - out = append(out, contract.TitleResponse{ - ID: t.ID, - Name: t.Name, - Code: t.Code, - Description: t.Description, - }) - } - return out -} - -func PermissionsToContract(perms []entities.Permission) []contract.PermissionResponse { - out := make([]contract.PermissionResponse, 0, len(perms)) - for _, p := range perms { - out = append(out, contract.PermissionResponse{ - ID: p.ID, - Code: p.Code, - Action: p.Action, - Description: &p.Description, - }) - } - return out -} - -func RoleWithPermissionsToContract(role entities.Role, perms []entities.Permission) contract.RoleWithPermissionsResponse { - return contract.RoleWithPermissionsResponse{ - ID: role.ID, - Name: role.Name, - Code: role.Code, - Description: &role.Description, - Permissions: PermissionsToContract(perms), - CreatedAt: role.CreatedAt, - UpdatedAt: role.UpdatedAt, - } -} - -func LabelsToContract(list []entities.Label) []contract.LabelResponse { - out := make([]contract.LabelResponse, 0, len(list)) - for _, e := range list { - out = append(out, contract.LabelResponse{ID: e.ID.String(), Name: e.Name, Color: e.Color, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt}) - } - return out -} - -func PrioritiesToContract(list []entities.Priority) []contract.PriorityResponse { - out := make([]contract.PriorityResponse, 0, len(list)) - for _, e := range list { - out = append(out, contract.PriorityResponse{ID: e.ID.String(), Name: e.Name, Level: e.Level, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt}) - } - return out -} - -func InstitutionsToContract(list []entities.Institution) []contract.InstitutionResponse { - out := make([]contract.InstitutionResponse, 0, len(list)) - for _, e := range list { - out = append(out, contract.InstitutionResponse{ID: e.ID.String(), Name: e.Name, Type: string(e.Type), Address: e.Address, ContactPerson: e.ContactPerson, Phone: e.Phone, Email: e.Email, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt}) - } - return out -} - -func DispositionActionsToContract(list []entities.DispositionAction) []contract.DispositionActionResponse { - out := make([]contract.DispositionActionResponse, 0, len(list)) - for _, e := range list { - out = append(out, contract.DispositionActionResponse{ - ID: e.ID.String(), - Code: e.Code, - Label: e.Label, - Description: e.Description, - RequiresNote: e.RequiresNote, - GroupName: e.GroupName, - SortOrder: e.SortOrder, - IsActive: e.IsActive, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - }) - } - return out -} - -func DispositionRoutesToContract(list []entities.DispositionRoute) []contract.DispositionRouteResponse { - out := make([]contract.DispositionRouteResponse, 0, len(list)) - for _, e := range list { - var allowed map[string]interface{} - if e.AllowedActions != nil { - allowed = map[string]interface{}(e.AllowedActions) - } - - resp := contract.DispositionRouteResponse{ - ID: e.ID, - FromDepartmentID: e.FromDepartmentID, - ToDepartmentID: e.ToDepartmentID, - IsActive: e.IsActive, - AllowedActions: allowed, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - } - - // Add department information if available - if e.FromDepartment.ID != uuid.Nil { - resp.FromDepartment = contract.DepartmentInfo{ - ID: e.FromDepartment.ID, - Name: e.FromDepartment.Name, - Code: e.FromDepartment.Code, - } - } - - if e.ToDepartment.ID != uuid.Nil { - resp.ToDepartment = contract.DepartmentInfo{ - ID: e.ToDepartment.ID, - Name: e.ToDepartment.Name, - Code: e.ToDepartment.Code, - } - } - - out = append(out, resp) - } - return out -} diff --git a/internal/transformer/letter_transformer.go b/internal/transformer/letter_transformer.go deleted file mode 100644 index cf95154..0000000 --- a/internal/transformer/letter_transformer.go +++ /dev/null @@ -1,444 +0,0 @@ -package transformer - -import ( - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" - - "github.com/google/uuid" -) - -func LetterEntityToContract(e *entities.LetterIncoming, attachments []entities.LetterIncomingAttachment, dispositions []entities.LetterIncomingDisposition, refs ...interface{}) *contract.IncomingLetterResponse { - resp := &contract.IncomingLetterResponse{ - ID: e.ID, - LetterNumber: e.LetterNumber, - ReferenceNumber: e.ReferenceNumber, - Subject: e.Subject, - Description: e.Description, - SenderName: e.SenderName, - Addressee: e.Addressee, - ReceivedDate: e.ReceivedDate, - DueDate: e.DueDate, - Type: string(e.Type), - Status: string(e.Status), - CreatedBy: e.CreatedBy, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - Attachments: make([]contract.IncomingLetterAttachmentResponse, 0, len(attachments)), - Dispositions: make([]contract.EnhancedDispositionResponse, 0, len(dispositions)), - } - - // optional refs: allow passing already-fetched related objects - // expected ordering (if provided): *entities.Priority, *entities.Institution - for _, r := range refs { - switch v := r.(type) { - case *entities.Priority: - if v != nil { - resp.Priority = &contract.PriorityResponse{ - ID: v.ID.String(), - Name: v.Name, - Level: v.Level, - CreatedAt: v.CreatedAt, - UpdatedAt: v.UpdatedAt, - } - } - case *entities.Institution: - if v != nil { - resp.SenderInstitution = &contract.InstitutionResponse{ - ID: v.ID.String(), - Name: v.Name, - Type: string(v.Type), - Address: v.Address, - ContactPerson: v.ContactPerson, - Phone: v.Phone, - Email: v.Email, - CreatedAt: v.CreatedAt, - UpdatedAt: v.UpdatedAt, - } - } - } - } - for _, a := range attachments { - resp.Attachments = append(resp.Attachments, contract.IncomingLetterAttachmentResponse{ - ID: a.ID, - FileURL: a.FileURL, - FileName: a.FileName, - FileType: a.FileType, - UploadedAt: a.UploadedAt, - }) - } - - for _, d := range dispositions { - resp.Dispositions = append(resp.Dispositions, contract.EnhancedDispositionResponse{ - ID: d.ID, - LetterID: d.LetterID, - DepartmentID: d.DepartmentID, - Notes: d.Notes, - ReadAt: d.ReadAt, - CreatedBy: d.CreatedBy, - CreatedAt: d.CreatedAt, - UpdatedAt: d.UpdatedAt, - Departments: DispositionDepartmentsWithDetailsToContract(d.Departments), - Actions: DispositionActionSelectionsWithDetailsToContract(d.ActionSelections), - DispositionNotes: DispositionNotesWithDetailsToContract(d.DispositionNotes), - Department: DepartmentToContract(d.Department), - }) - } - 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, - SenderName: e.SenderName, - Addressee: e.Addressee, - ReceivedDate: e.ReceivedDate, - DueDate: e.DueDate, - Type: string(e.Type), - 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 { - out = append(out, DispoToContract(d)) - } - return out -} - -func DispoToContract(d entities.LetterIncomingDisposition) contract.DispositionResponse { - return contract.DispositionResponse{ - ID: d.ID, - LetterID: d.LetterID, - DepartmentID: d.DepartmentID, - Notes: d.Notes, - ReadAt: d.ReadAt, - CreatedBy: d.CreatedBy, - CreatedAt: d.CreatedAt, - UpdatedAt: d.UpdatedAt, - } -} - -func EnhancedDispositionsToContract(list []entities.LetterIncomingDisposition) []contract.EnhancedDispositionResponse { - out := make([]contract.EnhancedDispositionResponse, 0, len(list)) - for _, d := range list { - resp := contract.EnhancedDispositionResponse{ - ID: d.ID, - LetterID: d.LetterID, - DepartmentID: d.DepartmentID, - Notes: d.Notes, - ReadAt: d.ReadAt, - CreatedBy: d.CreatedBy, - CreatedAt: d.CreatedAt, - UpdatedAt: d.UpdatedAt, - Departments: []contract.DispositionDepartmentResponse{}, - Actions: []contract.DispositionActionSelectionResponse{}, - DispositionNotes: []contract.DispositionNoteResponse{}, - } - out = append(out, resp) - } - return out -} - -func DispositionDepartmentsToContract(list []entities.LetterIncomingDispositionDepartment) []contract.DispositionDepartmentResponse { - out := make([]contract.DispositionDepartmentResponse, 0, len(list)) - for _, d := range list { - resp := contract.DispositionDepartmentResponse{ - ID: d.ID, - DepartmentID: d.DepartmentID, - CreatedAt: d.CreatedAt, - } - out = append(out, resp) - } - return out -} - -func DispositionDepartmentsWithDetailsToContract(list []entities.LetterIncomingDispositionDepartment) []contract.DispositionDepartmentResponse { - out := make([]contract.DispositionDepartmentResponse, 0, len(list)) - for _, d := range list { - resp := contract.DispositionDepartmentResponse{ - ID: d.ID, - DepartmentID: d.DepartmentID, - CreatedAt: d.CreatedAt, - } - - // Include department details if preloaded - if d.Department != nil { - resp.Department = &contract.DepartmentResponse{ - ID: d.Department.ID, - Name: d.Department.Name, - Code: d.Department.Code, - Path: d.Department.Path, - } - } - - out = append(out, resp) - } - return out -} - -func DispositionActionSelectionsToContract(list []entities.LetterDispositionActionSelection) []contract.DispositionActionSelectionResponse { - out := make([]contract.DispositionActionSelectionResponse, 0, len(list)) - for _, d := range list { - resp := contract.DispositionActionSelectionResponse{ - ID: d.ID, - ActionID: d.ActionID, - Action: nil, // Will be populated by processor - Note: d.Note, - CreatedBy: d.CreatedBy, - CreatedAt: d.CreatedAt, - } - out = append(out, resp) - } - return out -} - -func DispositionActionSelectionsWithDetailsToContract(list []entities.LetterDispositionActionSelection) []contract.DispositionActionSelectionResponse { - out := make([]contract.DispositionActionSelectionResponse, 0, len(list)) - for _, d := range list { - resp := contract.DispositionActionSelectionResponse{ - ID: d.ID, - ActionID: d.ActionID, - Action: nil, // Will be populated by processor - Note: d.Note, - CreatedBy: d.CreatedBy, - CreatedAt: d.CreatedAt, - } - - // Include action details if preloaded - if d.Action != nil { - resp.Action = &contract.DispositionActionResponse{ - ID: d.Action.ID.String(), - Code: d.Action.Code, - Label: d.Action.Label, - Description: d.Action.Description, - RequiresNote: d.Action.RequiresNote, - GroupName: d.Action.GroupName, - SortOrder: d.Action.SortOrder, - IsActive: d.Action.IsActive, - CreatedAt: d.Action.CreatedAt, - UpdatedAt: d.Action.UpdatedAt, - } - } - - out = append(out, resp) - } - return out -} - -func DispositionNotesToContract(list []entities.DispositionNote) []contract.DispositionNoteResponse { - out := make([]contract.DispositionNoteResponse, 0, len(list)) - for _, d := range list { - resp := contract.DispositionNoteResponse{ - ID: d.ID, - UserID: d.UserID, - Note: d.Note, - CreatedAt: d.CreatedAt, - } - out = append(out, resp) - } - return out -} - -func DispositionNotesWithDetailsToContract(list []entities.DispositionNote) []contract.DispositionNoteResponse { - out := make([]contract.DispositionNoteResponse, 0, len(list)) - for _, d := range list { - resp := contract.DispositionNoteResponse{ - ID: d.ID, - UserID: d.UserID, - Note: d.Note, - CreatedAt: d.CreatedAt, - } - - // Include user details if preloaded - if d.User != nil { - resp.User = &contract.UserResponse{ - ID: d.User.ID, - Name: d.User.Name, - Email: d.User.Email, - IsActive: d.User.IsActive, - CreatedAt: d.User.CreatedAt, - UpdatedAt: d.User.UpdatedAt, - } - } - - out = append(out, resp) - } - return out -} - -func DiscussionEntityToContract(e *entities.LetterDiscussion) *contract.LetterDiscussionResponse { - var mentions map[string]interface{} - if e.Mentions != nil { - mentions = map[string]interface{}(e.Mentions) - } - return &contract.LetterDiscussionResponse{ - ID: e.ID, - LetterID: e.LetterID, - ParentID: e.ParentID, - UserID: e.UserID, - Message: e.Message, - Mentions: mentions, - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - EditedAt: e.EditedAt, - } -} - -func DiscussionsWithPreloadedDataToContract(list []entities.LetterDiscussion, mentionedUsers []entities.User) []contract.LetterDiscussionResponse { - // Create a map for efficient user lookup - userMap := make(map[uuid.UUID]entities.User) - for _, user := range mentionedUsers { - userMap[user.ID] = user - } - - out := make([]contract.LetterDiscussionResponse, 0, len(list)) - for _, d := range list { - resp := contract.LetterDiscussionResponse{ - ID: d.ID, - LetterID: d.LetterID, - ParentID: d.ParentID, - UserID: d.UserID, - Message: d.Message, - Mentions: map[string]interface{}(d.Mentions), - CreatedAt: d.CreatedAt, - UpdatedAt: d.UpdatedAt, - EditedAt: d.EditedAt, - } - - // Include user profile if preloaded - if d.User != nil { - resp.User = &contract.UserResponse{ - ID: d.User.ID, - Name: d.User.Name, - Email: d.User.Email, - IsActive: d.User.IsActive, - CreatedAt: d.User.CreatedAt, - UpdatedAt: d.User.UpdatedAt, - } - - // Include user profile if available - if d.User.Profile != nil { - resp.User.Profile = &contract.UserProfileResponse{ - UserID: d.User.Profile.UserID, - FullName: d.User.Profile.FullName, - DisplayName: d.User.Profile.DisplayName, - Phone: d.User.Profile.Phone, - AvatarURL: d.User.Profile.AvatarURL, - JobTitle: d.User.Profile.JobTitle, - EmployeeNo: d.User.Profile.EmployeeNo, - Bio: d.User.Profile.Bio, - Timezone: d.User.Profile.Timezone, - Locale: d.User.Profile.Locale, - } - } - } - - // Process mentions to get mentioned users with profiles - if d.Mentions != nil { - mentions := map[string]interface{}(d.Mentions) - if userIDs, ok := mentions["user_ids"]; ok { - if userIDList, ok := userIDs.([]interface{}); ok { - mentionedUsersList := make([]contract.UserResponse, 0) - for _, userID := range userIDList { - if userIDStr, ok := userID.(string); ok { - if userUUID, err := uuid.Parse(userIDStr); err == nil { - if user, exists := userMap[userUUID]; exists { - userResp := contract.UserResponse{ - ID: user.ID, - Name: user.Name, - Email: user.Email, - IsActive: user.IsActive, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - } - - // Include user profile if available - if user.Profile != nil { - userResp.Profile = &contract.UserProfileResponse{ - UserID: user.Profile.UserID, - FullName: user.Profile.FullName, - DisplayName: user.Profile.DisplayName, - Phone: user.Profile.Phone, - AvatarURL: user.Profile.AvatarURL, - JobTitle: user.Profile.JobTitle, - EmployeeNo: user.Profile.EmployeeNo, - Bio: user.Profile.Bio, - Timezone: user.Profile.Timezone, - Locale: user.Profile.Locale, - } - } - mentionedUsersList = append(mentionedUsersList, userResp) - } - } - } - } - resp.MentionedUsers = mentionedUsersList - } - } - } - - out = append(out, resp) - } - return out -} - -func EnhancedDispositionsWithPreloadedDataToContract(list []entities.LetterIncomingDisposition) []contract.EnhancedDispositionResponse { - out := make([]contract.EnhancedDispositionResponse, 0, len(list)) - for _, d := range list { - resp := contract.EnhancedDispositionResponse{ - ID: d.ID, - LetterID: d.LetterID, - DepartmentID: d.DepartmentID, - Notes: d.Notes, - ReadAt: d.ReadAt, - CreatedBy: d.CreatedBy, - CreatedAt: d.CreatedAt, - UpdatedAt: d.UpdatedAt, - Departments: []contract.DispositionDepartmentResponse{}, - Actions: []contract.DispositionActionSelectionResponse{}, - DispositionNotes: []contract.DispositionNoteResponse{}, - Department: DepartmentToContract(d.Department), - } - - if len(d.Departments) > 0 { - resp.Departments = DispositionDepartmentsWithDetailsToContract(d.Departments) - } - - // Include preloaded action selections with details - if len(d.ActionSelections) > 0 { - resp.Actions = DispositionActionSelectionsWithDetailsToContract(d.ActionSelections) - } - - // Include preloaded notes with user details - if len(d.DispositionNotes) > 0 { - resp.DispositionNotes = DispositionNotesWithDetailsToContract(d.DispositionNotes) - } - - out = append(out, resp) - } - return out -} diff --git a/internal/transformer/repository_attachment_transformer.go b/internal/transformer/repository_attachment_transformer.go deleted file mode 100644 index 7f5386f..0000000 --- a/internal/transformer/repository_attachment_transformer.go +++ /dev/null @@ -1,49 +0,0 @@ -package transformer - -import ( - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" - - "github.com/google/uuid" -) - -func CreateRepositoryAttachmentRequestToEntity(req *contract.CreateRepositoryAttachmentRequest, userId uuid.UUID) *entities.RepositoryAttachment { - if req == nil { - return nil - } - return &entities.RepositoryAttachment{ - FileName: req.FileName, - FileType: req.FileType, - FileURL: req.FileURL, - UploadedBy: &userId, - Category: req.Category, - } -} - -func RepositoryAttachmentEntityToContract(entity *entities.RepositoryAttachment) *contract.RepositoryAttachmentsResponse { - resp := &contract.RepositoryAttachmentsResponse{ - ID: entity.ID, - FileName: entity.FileName, - FileType: entity.FileType, - FileURL: entity.FileURL, - Category: entity.Category, - UploadBy: *entity.UploadedBy, - UploadAt: entity.UploadedAt, - } - - return resp -} - -func RepositoryAttachmentEntityToContracts(attachments []*entities.RepositoryAttachment) []contract.RepositoryAttachmentsResponse { - if attachments == nil { - return nil - } - responses := make([]contract.RepositoryAttachmentsResponse, len(attachments)) - for i, u := range attachments { - resp := RepositoryAttachmentEntityToContract(u) - if resp != nil { - responses[i] = *resp - } - } - return responses -} diff --git a/internal/transformer/user_transformer.go b/internal/transformer/user_transformer.go index 22f25b1..08fa18d 100644 --- a/internal/transformer/user_transformer.go +++ b/internal/transformer/user_transformer.go @@ -1,8 +1,8 @@ package transformer import ( - "eslogad-be/internal/contract" - "eslogad-be/internal/entities" + "go-backend-template/internal/contract" + "go-backend-template/internal/entities" ) func CreateUserRequestToEntity(req *contract.CreateUserRequest, passwordHash string) *entities.User { @@ -29,7 +29,7 @@ func UpdateUserEntity(existing *entities.User, req *contract.UpdateUserRequest) if req.Email != nil { existing.Email = *req.Email } - + if req.IsActive != nil { existing.IsActive = *req.IsActive } @@ -41,27 +41,14 @@ func EntityToContract(user *entities.User) *contract.UserResponse { return nil } - // Use Profile.FullName if available, otherwise fall back to user.Name - displayName := user.Name - if user.Profile != nil && user.Profile.FullName != "" { - displayName = user.Profile.FullName - } - - resp := &contract.UserResponse{ + return &contract.UserResponse{ ID: user.ID, - Name: displayName, + Name: user.Name, Email: user.Email, IsActive: user.IsActive, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, } - if user.Profile != nil { - resp.Profile = ProfileEntityToContract(user.Profile) - } - if user.Departments != nil && len(user.Departments) > 0 { - resp.DepartmentResponse = DepartmentsToContract(user.Departments) - } - return resp } func EntitiesToContracts(users []*entities.User) []contract.UserResponse { diff --git a/internal/util/http_util.go b/internal/util/http_util.go index 759229a..33b618a 100644 --- a/internal/util/http_util.go +++ b/internal/util/http_util.go @@ -2,9 +2,9 @@ package util import ( "encoding/json" - "eslogad-be/internal/constants" - "eslogad-be/internal/contract" - "eslogad-be/internal/logger" + "go-backend-template/internal/constants" + "go-backend-template/internal/contract" + "go-backend-template/internal/logger" "net/http" "net/url" ) diff --git a/internal/validator/user_validator.go b/internal/validator/user_validator.go index 21f4b2b..83763b0 100644 --- a/internal/validator/user_validator.go +++ b/internal/validator/user_validator.go @@ -4,8 +4,8 @@ import ( "errors" "strings" - "eslogad-be/internal/constants" - "eslogad-be/internal/contract" + "go-backend-template/internal/constants" + "go-backend-template/internal/contract" "github.com/google/uuid" ) @@ -37,10 +37,6 @@ func (v *UserValidatorImpl) ValidateCreateUserRequest(req *contract.CreateUserRe return errors.New("password must be at least 6 characters"), constants.MalformedFieldErrorCode } - if req.RoleID == nil { - return errors.New("role is required"), constants.MissingFieldErrorCode - } - return nil, "" } @@ -49,7 +45,7 @@ func (v *UserValidatorImpl) ValidateUpdateUserRequest(req *contract.UpdateUserRe return errors.New("request body is required"), constants.MissingFieldErrorCode } - if req.Email == nil && req.Role == nil && req.IsActive == nil { + if req.Email == nil && req.IsActive == nil { return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode } @@ -62,15 +58,6 @@ func (v *UserValidatorImpl) ValidateUpdateUserRequest(req *contract.UpdateUserRe } } - // if req.Role != nil { - // if strings.TrimSpace(*req.Role) == "" { - // return errors.New("role cannot be empty"), constants.MalformedFieldErrorCode - // } - // // if !isValidUserRole(*req.Role) { - // // return errors.New("invalid user role"), constants.MalformedFieldErrorCode - // // } - // } - return nil, "" } @@ -91,10 +78,6 @@ func (v *UserValidatorImpl) ValidateListUsersRequest(req *contract.ListUsersRequ return errors.New("limit cannot exceed 100"), constants.MalformedFieldErrorCode } - if req.Role != nil && !isValidUserRole(*req.Role) { - return errors.New("invalid user role filter"), constants.MalformedFieldErrorCode - } - return nil, "" } @@ -129,21 +112,3 @@ func (v *UserValidatorImpl) ValidateUserID(userID uuid.UUID) (error, string) { return nil, "" } - -func isValidUserRole(role string) bool { - validRoles := map[string]bool{ - string(constants.RoleAdmin): true, - string(constants.RoleManager): true, - string(constants.RoleCashier): true, - string(constants.RoleWaiter): true, - } - return validRoles[role] -} - -func (v *UserValidatorImpl) ValidateUpdateUserOutletRequest(req *contract.UpdateUserOutletRequest) (error, string) { - if req.OutletID == uuid.Nil { - return errors.New("outlet_id is required"), constants.MissingFieldErrorCode - } - - return nil, "" -} diff --git a/migrations/000001_create_users_table.down.sql b/migrations/000001_create_users_table.down.sql new file mode 100644 index 0000000..8f54df9 --- /dev/null +++ b/migrations/000001_create_users_table.down.sql @@ -0,0 +1,2 @@ +-- Rollback: Drop users table +DROP TABLE IF EXISTS users; diff --git a/migrations/000001_create_users_table.up.sql b/migrations/000001_create_users_table.up.sql new file mode 100644 index 0000000..64f84a9 --- /dev/null +++ b/migrations/000001_create_users_table.up.sql @@ -0,0 +1,14 @@ +-- Example migration: Create users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(255) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(255), + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_username ON users(username); diff --git a/migrations/000001_init_db.down.sql b/migrations/000001_init_db.down.sql deleted file mode 100644 index 1241c00..0000000 --- a/migrations/000001_init_db.down.sql +++ /dev/null @@ -1,14 +0,0 @@ -DROP TABLE IF EXISTS position_roles; -DROP TABLE IF EXISTS user_position; -DROP TABLE IF EXISTS user_department; -DROP TABLE IF EXISTS positions; -DROP TABLE IF EXISTS departments; - -DROP TABLE IF EXISTS role_permissions; -DROP TABLE IF EXISTS user_role; -DROP TABLE IF EXISTS permissions; -DROP TABLE IF EXISTS roles; - -DROP TABLE IF EXISTS users; - -DROP FUNCTION IF EXISTS set_updated_at() CASCADE; diff --git a/migrations/000001_init_db.up.sql b/migrations/000001_init_db.up.sql deleted file mode 100644 index aeea125..0000000 --- a/migrations/000001_init_db.up.sql +++ /dev/null @@ -1,146 +0,0 @@ --- ESLOGAD Core Init (Users, Roles, Permissions, Departments, Positions) - -CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -CREATE EXTENSION IF NOT EXISTS ltree; - --- Helper to auto-update updated_at -CREATE OR REPLACE FUNCTION set_updated_at() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; -RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- ======================= --- USERS --- ======================= -CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - username VARCHAR(100) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL, - name VARCHAR(255), - email VARCHAR(255) UNIQUE, - status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active','inactive')), - is_active BOOLEAN NOT NULL DEFAULT TRUE, - last_login_at TIMESTAMP WITHOUT TIME ZONE, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); -CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); -CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); - -CREATE TRIGGER trg_users_updated_at - BEFORE UPDATE ON users - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- ROLES & PERMISSIONS --- ======================= -CREATE TABLE IF NOT EXISTS roles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, -- e.g., SUPERADMIN - code TEXT UNIQUE NOT NULL, -- e.g., superadmin - description TEXT, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP - ); -CREATE TRIGGER trg_roles_updated_at - BEFORE UPDATE ON roles - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -CREATE TABLE IF NOT EXISTS permissions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code TEXT UNIQUE NOT NULL, -- e.g., letter.view - description TEXT, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TRIGGER trg_permissions_updated_at - BEFORE UPDATE ON permissions - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -CREATE TABLE IF NOT EXISTS user_role ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - assigned_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - removed_at TIMESTAMP WITHOUT TIME ZONE -) -; -CREATE UNIQUE INDEX IF NOT EXISTS uq_user_role_active - ON user_role(user_id, role_id) WHERE removed_at IS NULL; - -CREATE TABLE IF NOT EXISTS role_permissions ( - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, - PRIMARY KEY (role_id, permission_id) - ); - -CREATE TABLE IF NOT EXISTS departments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - code TEXT UNIQUE, - path LTREE UNIQUE NOT NULL, -- e.g., eslogad.aslog.waaslog_faskon_bmn - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP - ); -CREATE INDEX IF NOT EXISTS idx_departments_path_gist - ON departments USING GIST (path); - -CREATE TRIGGER trg_departments_updated_at - BEFORE UPDATE ON departments - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -CREATE TABLE IF NOT EXISTS positions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, -- e.g., PABAN III/FASKON - code TEXT UNIQUE, -- e.g., paban-III-faskon - path LTREE UNIQUE NOT NULL, -- hierarchy within org chart - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_positions_path_gist - ON positions USING GIST (path); - -CREATE TRIGGER trg_positions_updated_at - BEFORE UPDATE ON positions - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -CREATE TABLE IF NOT EXISTS user_department ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - department_id UUID NOT NULL REFERENCES departments(id) ON DELETE CASCADE, - is_primary BOOLEAN NOT NULL DEFAULT FALSE, - assigned_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - removed_at TIMESTAMP WITHOUT TIME ZONE -); - -CREATE UNIQUE INDEX IF NOT EXISTS uq_user_department_active - ON user_department(user_id, department_id) WHERE removed_at IS NULL; - -CREATE TRIGGER trg_user_department_updated_at - BEFORE UPDATE ON user_department - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -CREATE TABLE IF NOT EXISTS user_position ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - position_id UUID NOT NULL REFERENCES positions(id) ON DELETE CASCADE, - assigned_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - removed_at TIMESTAMP WITHOUT TIME ZONE -); -CREATE UNIQUE INDEX IF NOT EXISTS uq_user_position_active - ON user_position(user_id, position_id) WHERE removed_at IS NULL; - -CREATE TRIGGER trg_user_position_updated_at - BEFORE UPDATE ON user_position - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -CREATE TABLE IF NOT EXISTS position_roles ( - position_id UUID NOT NULL REFERENCES positions(id) ON DELETE CASCADE, - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - PRIMARY KEY (position_id, role_id) -); \ No newline at end of file diff --git a/migrations/000002_create_user_profiles_table.down.sql b/migrations/000002_create_user_profiles_table.down.sql new file mode 100644 index 0000000..bab522d --- /dev/null +++ b/migrations/000002_create_user_profiles_table.down.sql @@ -0,0 +1,2 @@ +-- Rollback: Drop user profiles table +DROP TABLE IF EXISTS user_profiles; diff --git a/migrations/000002_create_user_profiles_table.up.sql b/migrations/000002_create_user_profiles_table.up.sql new file mode 100644 index 0000000..18747c6 --- /dev/null +++ b/migrations/000002_create_user_profiles_table.up.sql @@ -0,0 +1,13 @@ +-- Example migration: Create user profiles table +CREATE TABLE IF NOT EXISTS user_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + avatar_url VARCHAR(500), + bio TEXT, + phone VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id) +); + +CREATE INDEX idx_user_profiles_user_id ON user_profiles(user_id); diff --git a/migrations/000002_seed_user_data.down.sql b/migrations/000002_seed_user_data.down.sql deleted file mode 100644 index e69de29..0000000 diff --git a/migrations/000002_seed_user_data.up.sql b/migrations/000002_seed_user_data.up.sql deleted file mode 100644 index 1f36b6c..0000000 --- a/migrations/000002_seed_user_data.up.sql +++ /dev/null @@ -1,119 +0,0 @@ -BEGIN; - --- ========================= --- Positions (hierarchy) --- ========================= --- Conventions: --- - superadmin is a separate root --- - eslogad.aslog is head; waaslog_* under aslog --- - paban_* under each waaslog_*; pabandya_* under its paban_* -INSERT INTO departments (name, code, path) VALUES - -- ROOTS - ('SUPERADMIN', 'superadmin', 'superadmin'), - ('ASLOG', 'aslog', 'eslogad.aslog'), - - -- WAASLOG under ASLOG - ('WAASLOG RENBINMINLOG', 'waaslogrenbinminlog', 'eslogad.aslog.waaslog_renbinminlog'), - ('WAASLOG FASKON BMN', 'waaslogfaskonbmn', 'eslogad.aslog.waaslog_faskon_bmn'), - ('WAASLOG BEKPALKES', 'waaslogbekpalkes', 'eslogad.aslog.waaslog_bekpalkes'), - - -- Other posts directly under ASLOG - ('KADISADAAD', 'kadisadaad', 'eslogad.aslog.kadisadaad'), - ('KATUUD', 'katuud', 'eslogad.aslog.katuud'), - ('SPRI', 'spri', 'eslogad.aslog.spri'), - - -- PABAN under WAASLOG RENBINMINLOG - ('PABAN I/REN', 'paban-I-ren', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren'), - ('PABAN II/BINMINLOG', 'paban-II-binminlog', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog'), - - -- PABAN under WAASLOG FASKON BMN - ('PABAN III/FASKON', 'paban-III-faskon', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon'), - ('PABAN IV/BMN', 'paban-iv-bmn', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn'), - - -- PABAN under WAASLOG BEKPALKES - ('PABAN V/BEK', 'paban-v-bek', 'eslogad.aslog.waaslog_bekpalkes.paban_V_bek'), - ('PABAN VI/ALPAL', 'paban-vi-alpal', 'eslogad.aslog.waaslog_bekpalkes.paban_VI_alpal'), - ('PABAN VII/KES', 'paban-vii-kes', 'eslogad.aslog.waaslog_bekpalkes.paban_VII_kes'), - - -- PABANDYA under PABAN I/REN - ('PABANDYA 1 / RENPROGGAR', 'pabandya-1-renproggar', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren.pabandya_1_renproggar'), - ('PABANDYA 2 / DALWASGAR', 'pabandya-2-dalwasgar', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren.pabandya_2_dalwasgar'), - ('PABANDYA 3 / ANEVDATA', 'pabandya-3-anevdata', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren.pabandya_3_anevdata'), - - -- PABANDYA under PABAN II/BINMINLOG - ('PABANDYA 1 / MINLOG', 'pabandya-1-minlog', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog.pabandya_1_minlog'), - ('PABANDYA 2 / HIBAHKOD', 'pabandya-2-hibahkod', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog.pabandya_2_hibahkod'), - ('PABANDYA 3 / PUSMAT', 'pabandya-3-pusmat', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog.pabandya_3_pusmat'), - - -- PABANDYA under PABAN IV/BMN - ('PABANDYA 1 / TANAH', 'pabandya-1-tanah', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_1_tanah'), - ('PABANDYA 2 / PANGKALAN KONSTRUKSI','pabandya-2-pangkalankonstruksi','eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_2_pangkalan_konstruksi'), - ('PABANDYA 3 / FASMATZI', 'pabandya-3-fasmatzi', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_3_fasmatzi'), - - -- PABANDYA under PABAN IV/BMN (AKUN group) - ('PABANDYA 1 / AKUN BB', 'pabandya-1-akunbb', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_1_akun_bb'), - ('PABANDYA 2 / AKUN BTB', 'pabandya-2-akunbtb', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_2_akun_btb'), - ('PABANDYA 3 / SISFO BMN DAN UAKPB-KP','pabandya-3-sisfo-bmn-uakpbkp','eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_3_sisfo_bmn_uakpb_kp'), - - -- PABANDYA under PABAN III/FASKON - ('PABANDYA 1 / JATOPTIKMU', 'pabandya-1-jatoptikmu', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_1_jatoptikmu'), - ('PABANDYA 2 / RANTEKMEK', 'pabandya-2-rantekmek', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_2_rantekmek'), - ('PABANDYA 3 / ALHUBTOPPALSUS', 'pabandya-3-alhubtoppalsus', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_3_alhubtoppalsus'), - ('PABANDYA 4 / PESUD', 'pabandya-4-pesud', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_4_pesud'), - - -- PABANDYA under PABAN VII/KES - ('PABANDYA 1 / BEKKES', 'pabandya-1-bekkes', 'eslogad.aslog.waaslog_bekpalkes.paban_VII_kes.pabandya_1_bekkes'), - ('PABANDYA 2 / ALKES', 'pabandya-2-alkes', 'eslogad.aslog.waaslog_bekpalkes.paban_VII_kes.pabandya_2_alkes') -ON CONFLICT (code) DO UPDATE - SET name = EXCLUDED.name, - path = EXCLUDED.path, - updated_at = CURRENT_TIMESTAMP; - --- ========================= --- SUPERADMIN role (minimal) --- ========================= -INSERT INTO roles (name, code, description) VALUES - ('SUPERADMIN', 'superadmin', 'Full system access and management'), - ('ADMIN', 'admin', 'Manage users, letters, and settings within their department'), - ('HEAD', 'head', 'Approve outgoing letters and manage dispositions in their department'), - ('STAFF', 'staff', 'Create letters, process assigned dispositions') -ON CONFLICT (code) DO UPDATE - SET name = EXCLUDED.name, - description = EXCLUDED.description, - updated_at = CURRENT_TIMESTAMP; - --- ========================= --- Users (seed 1 superadmin) --- ========================= --- Replace the plaintext password as needed; pgcrypto hashes it with bcrypt. -INSERT INTO users (username, password_hash, name, email, status, is_active) -VALUES ('superadmin', - crypt('ChangeMe!Super#123', gen_salt('bf')), - 'Super Admin', - 'superadmin@example.com', - 'active', - TRUE) - ON CONFLICT (username) DO UPDATE - SET name = EXCLUDED.name, - email = EXCLUDED.email, - status = EXCLUDED.status, - is_active = EXCLUDED.is_active, - updated_at = CURRENT_TIMESTAMP; - --- ========================= --- Link: SUPERADMIN user ↔ role ↔ position --- ========================= -WITH u AS (SELECT id FROM users WHERE username = 'superadmin'), - r AS (SELECT id FROM roles WHERE code = 'superadmin'), - p AS (SELECT id FROM positions WHERE code = 'superadmin') -INSERT INTO user_role (user_id, role_id) -SELECT u.id, r.id FROM u, r - ON CONFLICT (user_id, role_id) WHERE removed_at IS NULL DO NOTHING; - -WITH u AS (SELECT id FROM users WHERE username = 'superadmin'), - p AS (SELECT id FROM positions WHERE code = 'superadmin') -INSERT INTO user_position (user_id, position_id) -SELECT u.id, p.id FROM u, p - ON CONFLICT (user_id, position_id) WHERE removed_at IS NULL DO NOTHING; - -COMMIT; diff --git a/migrations/000003_permissions_seeder.down.sql b/migrations/000003_permissions_seeder.down.sql deleted file mode 100644 index e69de29..0000000 diff --git a/migrations/000003_permissions_seeder.up.sql b/migrations/000003_permissions_seeder.up.sql deleted file mode 100644 index 3f09b1f..0000000 --- a/migrations/000003_permissions_seeder.up.sql +++ /dev/null @@ -1,30 +0,0 @@ -INSERT INTO permissions (id, code, description, created_at, updated_at) VALUES --- Users -(gen_random_uuid(), 'user.read', 'View user list and details', now(), now()), -(gen_random_uuid(), 'user.create', 'Create new users', now(), now()), -(gen_random_uuid(), 'user.update', 'Edit existing users', now(), now()), -(gen_random_uuid(), 'user.delete', 'Delete users', now(), now()), - --- Roles -(gen_random_uuid(), 'role.read', 'View roles', now(), now()), -(gen_random_uuid(), 'role.create', 'Create new roles', now(), now()), -(gen_random_uuid(), 'role.update', 'Edit existing roles', now(), now()), -(gen_random_uuid(), 'role.delete', 'Delete roles', now(), now()), - --- Permissions -(gen_random_uuid(), 'permission.read', 'View permissions', now(), now()), -(gen_random_uuid(), 'permission.create', 'Create new permissions', now(), now()), -(gen_random_uuid(), 'permission.update', 'Edit existing permissions', now(), now()), -(gen_random_uuid(), 'permission.delete', 'Delete permissions', now(), now()), - --- Departments -(gen_random_uuid(), 'department.read', 'View departments', now(), now()), -(gen_random_uuid(), 'department.create', 'Create new departments', now(), now()), -(gen_random_uuid(), 'department.update', 'Edit existing departments', now(), now()), -(gen_random_uuid(), 'department.delete', 'Delete departments', now(), now()), - --- Positions -(gen_random_uuid(), 'position.read', 'View positions', now(), now()), -(gen_random_uuid(), 'position.create', 'Create new positions', now(), now()), -(gen_random_uuid(), 'position.update', 'Edit existing positions', now(), now()), -(gen_random_uuid(), 'position.delete', 'Delete positions', now(), now()); diff --git a/migrations/000004_user_profile.down.sql b/migrations/000004_user_profile.down.sql deleted file mode 100644 index b48a59a..0000000 --- a/migrations/000004_user_profile.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS user_profiles; diff --git a/migrations/000004_user_profile.up.sql b/migrations/000004_user_profile.up.sql deleted file mode 100644 index ff1b2ae..0000000 --- a/migrations/000004_user_profile.up.sql +++ /dev/null @@ -1,30 +0,0 @@ -BEGIN; - -CREATE TABLE IF NOT EXISTS user_profiles ( - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - full_name VARCHAR(150) NOT NULL, - display_name VARCHAR(100), - phone VARCHAR(50), - avatar_url TEXT, - job_title VARCHAR(120), - employee_no VARCHAR(60), - bio TEXT, - timezone VARCHAR(64) DEFAULT 'Asia/Jakarta', - locale VARCHAR(16) DEFAULT 'id-ID', - preferences JSONB NOT NULL DEFAULT '{}'::jsonb, - notification_prefs JSONB NOT NULL DEFAULT '{}'::jsonb, - last_seen_at TIMESTAMP WITHOUT TIME ZONE, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_user_profiles_phone ON user_profiles(phone); -CREATE INDEX IF NOT EXISTS idx_user_profiles_employee_no ON user_profiles(employee_no); -CREATE INDEX IF NOT EXISTS idx_user_profiles_prefs_gin ON user_profiles USING GIN (preferences); -CREATE INDEX IF NOT EXISTS idx_user_profiles_notif_gin ON user_profiles USING GIN (notification_prefs); - -CREATE TRIGGER trg_user_profiles_updated_at - BEFORE UPDATE ON user_profiles - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -COMMIT; diff --git a/migrations/000005_title_table.down.sql b/migrations/000005_title_table.down.sql deleted file mode 100644 index e69de29..0000000 diff --git a/migrations/000005_title_table.up.sql b/migrations/000005_title_table.up.sql deleted file mode 100644 index 973039d..0000000 --- a/migrations/000005_title_table.up.sql +++ /dev/null @@ -1,60 +0,0 @@ --- ======================= --- TITLES --- ======================= -CREATE TABLE IF NOT EXISTS titles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, -- e.g., "Senior Software Engineer" - code TEXT UNIQUE, -- e.g., "senior-software-engineer" - description TEXT, -- optional: extra details - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP - ); - --- Trigger for updated_at -CREATE TRIGGER trg_titles_updated_at - BEFORE UPDATE ON titles - FOR EACH ROW - EXECUTE FUNCTION set_updated_at(); - --- Perwira Tinggi (High-ranking Officers) -INSERT INTO titles (name, code, description) VALUES - ('Jenderal', 'jenderal', 'Pangkat tertinggi di TNI AD'), - ('Letnan Jenderal', 'letnan-jenderal', 'Pangkat tinggi di bawah Jenderal'), - ('Mayor Jenderal', 'mayor-jenderal', 'Pangkat tinggi di bawah Letnan Jenderal'), - ('Brigadir Jenderal', 'brigadir-jenderal', 'Pangkat tinggi di bawah Mayor Jenderal'); - --- Perwira Menengah (Middle-ranking Officers) -INSERT INTO titles (name, code, description) VALUES - ('Kolonel', 'kolonel', 'Pangkat perwira menengah tertinggi'), - ('Letnan Kolonel', 'letnan-kolonel', 'Pangkat perwira menengah di bawah Kolonel'), - ('Mayor', 'mayor', 'Pangkat perwira menengah di bawah Letnan Kolonel'); - --- Perwira Pertama (Junior Officers) -INSERT INTO titles (name, code, description) VALUES - ('Kapten', 'kapten', 'Pangkat perwira pertama tertinggi'), - ('Letnan Satu', 'letnan-satu', 'Pangkat perwira pertama di bawah Kapten'), - ('Letnan Dua', 'letnan-dua', 'Pangkat perwira pertama di bawah Letnan Satu'); - --- Bintara Tinggi (Senior NCOs) -INSERT INTO titles (name, code, description) VALUES - ('Pembantu Letnan Satu', 'pembantu-letnan-satu', 'Pangkat bintara tinggi tertinggi'), - ('Pembantu Letnan Dua', 'pembantu-letnan-dua', 'Pangkat bintara tinggi di bawah Pelda'); - --- Bintara (NCOs) -INSERT INTO titles (name, code, description) VALUES - ('Sersan Mayor', 'sersan-mayor', 'Pangkat bintara di bawah Pelda'), - ('Sersan Kepala', 'sersan-kepala', 'Pangkat bintara di bawah Serma'), - ('Sersan Satu', 'sersan-satu', 'Pangkat bintara di bawah Serka'), - ('Sersan Dua', 'sersan-dua', 'Pangkat bintara di bawah Sertu'); - --- Tamtama Tinggi (Senior Enlisted) -INSERT INTO titles (name, code, description) VALUES - ('Kopral Kepala', 'kopral-kepala', 'Pangkat tamtama tinggi tertinggi'), - ('Kopral Satu', 'kopral-satu', 'Pangkat tamtama tinggi di bawah Kopka'), - ('Kopral Dua', 'kopral-dua', 'Pangkat tamtama tinggi di bawah Koptu'); - --- Tamtama (Enlisted) -INSERT INTO titles (name, code, description) VALUES - ('Prajurit Kepala', 'prajurit-kepala', 'Pangkat tamtama di bawah Kopda'), - ('Prajurit Satu', 'prajurit-satu', 'Pangkat tamtama di bawah Prada'), - ('Prajurit Dua', 'prajurit-dua', 'Pangkat tamtama terendah'); diff --git a/migrations/000006_labels_priorities_institutions.down.sql b/migrations/000006_labels_priorities_institutions.down.sql deleted file mode 100644 index a720293..0000000 --- a/migrations/000006_labels_priorities_institutions.down.sql +++ /dev/null @@ -1,7 +0,0 @@ -BEGIN; - -DROP TABLE IF EXISTS institutions; -DROP TABLE IF EXISTS priorities; -DROP TABLE IF EXISTS labels; - -COMMIT; \ No newline at end of file diff --git a/migrations/000006_labels_priorities_institutions.up.sql b/migrations/000006_labels_priorities_institutions.up.sql deleted file mode 100644 index 6bf3307..0000000 --- a/migrations/000006_labels_priorities_institutions.up.sql +++ /dev/null @@ -1,52 +0,0 @@ -BEGIN; - --- ======================= --- LABELS --- ======================= -CREATE TABLE IF NOT EXISTS labels ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - color VARCHAR(16), -- HEX color code (e.g., #FF0000) - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TRIGGER trg_labels_updated_at - BEFORE UPDATE ON labels - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- PRIORITIES --- ======================= -CREATE TABLE IF NOT EXISTS priorities ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - level INT NOT NULL, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TRIGGER trg_priorities_updated_at - BEFORE UPDATE ON priorities - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- INSTITUTIONS --- ======================= -CREATE TABLE IF NOT EXISTS institutions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - type TEXT NOT NULL CHECK (type IN ('government','private','ngo','individual')), - address TEXT, - contact_person VARCHAR(255), - phone VARCHAR(50), - email VARCHAR(255), - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TRIGGER trg_institutions_updated_at - BEFORE UPDATE ON institutions - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -COMMIT; \ No newline at end of file diff --git a/migrations/000007_disposition_actions.down.sql b/migrations/000007_disposition_actions.down.sql deleted file mode 100644 index 8a9a41c..0000000 --- a/migrations/000007_disposition_actions.down.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN; - -DROP TABLE IF EXISTS disposition_actions; - -COMMIT; \ No newline at end of file diff --git a/migrations/000007_disposition_actions.up.sql b/migrations/000007_disposition_actions.up.sql deleted file mode 100644 index dc8b68e..0000000 --- a/migrations/000007_disposition_actions.up.sql +++ /dev/null @@ -1,23 +0,0 @@ -BEGIN; - --- ======================= --- DISPOSITION ACTIONS --- ======================= -CREATE TABLE IF NOT EXISTS disposition_actions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code TEXT UNIQUE NOT NULL, - label TEXT NOT NULL, - description TEXT, - requires_note BOOLEAN NOT NULL DEFAULT FALSE, - group_name TEXT, - sort_order INT, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TRIGGER trg_disposition_actions_updated_at - BEFORE UPDATE ON disposition_actions - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -COMMIT; \ No newline at end of file diff --git a/migrations/000008_letters_incoming_suite.down.sql b/migrations/000008_letters_incoming_suite.down.sql deleted file mode 100644 index 4b4f635..0000000 --- a/migrations/000008_letters_incoming_suite.down.sql +++ /dev/null @@ -1,17 +0,0 @@ -BEGIN; - -DROP TABLE IF EXISTS letter_incoming_activity_logs; -DROP TABLE IF EXISTS letter_incoming_discussion_attachments; -DROP TABLE IF EXISTS letter_incoming_discussions; -DROP TABLE IF EXISTS letter_disposition_actions; -DROP TABLE IF EXISTS disposition_notes; -DROP TABLE IF EXISTS letter_incoming_dispositions_department; -DROP TABLE IF EXISTS letter_incoming_dispositions; -DROP TABLE IF EXISTS letter_incoming_attachments; -DROP TABLE IF EXISTS letter_incoming_labels; -DROP TABLE IF EXISTS letter_incoming_recipients; -DROP TABLE IF EXISTS letters_incoming; - -DROP SEQUENCE IF EXISTS letters_incoming_seq; - -COMMIT; \ No newline at end of file diff --git a/migrations/000008_letters_incoming_suite.up.sql b/migrations/000008_letters_incoming_suite.up.sql deleted file mode 100644 index 13eed90..0000000 --- a/migrations/000008_letters_incoming_suite.up.sql +++ /dev/null @@ -1,189 +0,0 @@ -BEGIN; - --- ======================= --- SEQUENCE FOR LETTER NUMBER --- ======================= -CREATE SEQUENCE IF NOT EXISTS letters_incoming_seq; - --- ======================= --- LETTERS INCOMING --- ======================= -CREATE TABLE IF NOT EXISTS letters_incoming ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_number TEXT NOT NULL UNIQUE DEFAULT ('IN-' || lpad(nextval('letters_incoming_seq')::text, 8, '0')), - reference_number TEXT, - subject TEXT NOT NULL, - description TEXT, - priority_id UUID REFERENCES priorities(id) ON DELETE SET NULL, - sender_institution_id UUID REFERENCES institutions(id) ON DELETE SET NULL, - received_date DATE NOT NULL, - due_date DATE, - status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','in_progress','completed')), - created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITHOUT TIME ZONE -); - -CREATE INDEX IF NOT EXISTS idx_letters_incoming_status ON letters_incoming(status); -CREATE INDEX IF NOT EXISTS idx_letters_incoming_received_date ON letters_incoming(received_date); - -CREATE TRIGGER trg_letters_incoming_updated_at - BEFORE UPDATE ON letters_incoming - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- LETTER INCOMING RECIPIENTS --- ======================= -CREATE TABLE IF NOT EXISTS letter_incoming_recipients ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE, - recipient_user_id UUID REFERENCES users(id) ON DELETE SET NULL, - recipient_department_id UUID REFERENCES departments(id) ON DELETE SET NULL, - status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','read','completed')), - read_at TIMESTAMP WITHOUT TIME ZONE, - completed_at TIMESTAMP WITHOUT TIME ZONE, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -);I - -CREATE INDEX IF NOT EXISTS idx_letter_incoming_recipients_letter ON letter_incoming_recipients(letter_id); - --- ======================= --- LETTER INCOMING LABELS (M:N) --- ======================= -CREATE TABLE IF NOT EXISTS letter_incoming_labels ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE, - label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - UNIQUE (letter_id, label_id) -); - -CREATE INDEX IF NOT EXISTS idx_letter_incoming_labels_letter ON letter_incoming_labels(letter_id); - --- ======================= --- LETTER INCOMING ATTACHMENTS --- ======================= -CREATE TABLE IF NOT EXISTS letter_incoming_attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE, - file_url TEXT NOT NULL, - file_name TEXT NOT NULL, - file_type TEXT NOT NULL, - uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL, - uploaded_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_letter_incoming_attachments_letter ON letter_incoming_attachments(letter_id); - --- ======================= --- LETTER DISPOSITIONS --- ======================= -CREATE TABLE IF NOT EXISTS letter_dispositions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE, - from_user_id UUID REFERENCES users(id) ON DELETE SET NULL, - from_department_id UUID REFERENCES departments(id) ON DELETE SET NULL, - to_user_id UUID REFERENCES users(id) ON DELETE SET NULL, - to_department_id UUID REFERENCES departments(id) ON DELETE SET NULL, - notes TEXT, - status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','read','rejected','completed')), - created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - read_at TIMESTAMP WITHOUT TIME ZONE, - completed_at TIMESTAMP WITHOUT TIME ZONE, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_letter_dispositions_letter ON letter_dispositions(letter_id); - -CREATE TRIGGER trg_letter_dispositions_updated_at - BEFORE UPDATE ON letter_dispositions - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- DISPOSITION NOTES --- ======================= -CREATE TABLE IF NOT EXISTS disposition_notes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - disposition_id UUID NOT NULL REFERENCES letter_incoming_dispositions(id) ON DELETE CASCADE, - user_id UUID REFERENCES users(id) ON DELETE SET NULL, - note TEXT NOT NULL, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_disposition_notes_disposition ON disposition_notes(disposition_id); - --- ======================= --- LETTER DISPOSITION ACTIONS (Selections) --- ======================= -CREATE TABLE IF NOT EXISTS letter_disposition_actions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - disposition_id UUID NOT NULL REFERENCES letter_incoming_dispositions(id) ON DELETE CASCADE, - action_id UUID NOT NULL REFERENCES disposition_actions(id) ON DELETE RESTRICT, - note TEXT, - created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - UNIQUE (disposition_id, action_id) -); - -CREATE INDEX IF NOT EXISTS idx_letter_disposition_actions_disposition ON letter_disposition_actions(disposition_id); - --- ======================= --- LETTER INCOMING DISCUSSIONS (Threaded) --- ======================= -CREATE TABLE IF NOT EXISTS letter_incoming_discussions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE, - parent_id UUID REFERENCES letter_incoming_discussions(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - message TEXT NOT NULL, - mentions JSONB, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - edited_at TIMESTAMP WITHOUT TIME ZONE -); - -CREATE INDEX IF NOT EXISTS idx_letter_incoming_discussions_letter ON letter_incoming_discussions(letter_id); -CREATE INDEX IF NOT EXISTS idx_letter_incoming_discussions_parent ON letter_incoming_discussions(parent_id); - -CREATE TRIGGER trg_letter_incoming_discussions_updated_at - BEFORE UPDATE ON letter_incoming_discussions - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- LETTER INCOMING DISCUSSION ATTACHMENTS --- ======================= -CREATE TABLE IF NOT EXISTS letter_incoming_discussion_attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - discussion_id UUID NOT NULL REFERENCES letter_incoming_discussions(id) ON DELETE CASCADE, - file_url TEXT NOT NULL, - file_name TEXT NOT NULL, - file_type TEXT NOT NULL, - uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL, - uploaded_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_letter_incoming_discussion_attachments_discussion ON letter_incoming_discussion_attachments(discussion_id); - --- ======================= --- LETTER INCOMING ACTIVITY LOGS (Immutable) --- ======================= -CREATE TABLE IF NOT EXISTS letter_incoming_activity_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_incoming(id) ON DELETE CASCADE, - action_type TEXT NOT NULL, - actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL, - actor_department_id UUID REFERENCES departments(id) ON DELETE SET NULL, - target_type TEXT, - target_id UUID, - from_status TEXT, - to_status TEXT, - context JSONB, - occurred_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_letter_incoming_activity_logs_letter ON letter_incoming_activity_logs(letter_id); -CREATE INDEX IF NOT EXISTS idx_letter_incoming_activity_logs_action ON letter_incoming_activity_logs(action_type); - -COMMIT; \ No newline at end of file diff --git a/migrations/000009_disposition_routes.down.sql b/migrations/000009_disposition_routes.down.sql deleted file mode 100644 index edfdb46..0000000 --- a/migrations/000009_disposition_routes.down.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN; - -DROP TABLE IF EXISTS disposition_routes; - -COMMIT; \ No newline at end of file diff --git a/migrations/000009_disposition_routes.up.sql b/migrations/000009_disposition_routes.up.sql deleted file mode 100644 index ef987ea..0000000 --- a/migrations/000009_disposition_routes.up.sql +++ /dev/null @@ -1,27 +0,0 @@ -BEGIN; - --- ======================= --- DISPOSITION ROUTES --- ======================= -CREATE TABLE IF NOT EXISTS disposition_routes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - from_department_id UUID NOT NULL REFERENCES departments(id) ON DELETE CASCADE, - to_department_id UUID NOT NULL REFERENCES departments(id) ON DELETE CASCADE, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - allowed_actions JSONB, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_disposition_routes_from_dept ON disposition_routes(from_department_id); - --- Prevent duplicate active routes from -> to -CREATE UNIQUE INDEX IF NOT EXISTS uq_disposition_routes_active - ON disposition_routes(from_department_id, to_department_id) - WHERE is_active = TRUE; - -CREATE TRIGGER trg_disposition_routes_updated_at - BEFORE UPDATE ON disposition_routes - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -COMMIT; \ No newline at end of file diff --git a/migrations/000011_settings.down.sql b/migrations/000011_settings.down.sql deleted file mode 100644 index fb1ec1a..0000000 --- a/migrations/000011_settings.down.sql +++ /dev/null @@ -1,4 +0,0 @@ -BEGIN; -DROP TRIGGER IF EXISTS trg_app_settings_updated_at ON app_settings; -DROP TABLE IF EXISTS app_settings; -COMMIT; \ No newline at end of file diff --git a/migrations/000011_settings.up.sql b/migrations/000011_settings.up.sql deleted file mode 100644 index 91eee5b..0000000 --- a/migrations/000011_settings.up.sql +++ /dev/null @@ -1,21 +0,0 @@ -BEGIN; - -CREATE TABLE IF NOT EXISTS app_settings ( - key TEXT PRIMARY KEY, - value JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TRIGGER trg_app_settings_updated_at - BEFORE UPDATE ON app_settings - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -INSERT INTO app_settings(key, value) -VALUES - ('INCOMING_LETTER_PREFIX', '{"value": "ESLI"}'::jsonb), - ('INCOMING_LETTER_SEQUENCE', '{"value": 0}'::jsonb), - ('INCOMING_LETTER_RECIPIENTS', '{"department_codes": ["aslog"]}'::jsonb) -ON CONFLICT (key) DO NOTHING; - -COMMIT; \ No newline at end of file diff --git a/migrations/000012_rename_dispositions_table.down.sql b/migrations/000012_rename_dispositions_table.down.sql deleted file mode 100644 index deb72d5..0000000 --- a/migrations/000012_rename_dispositions_table.down.sql +++ /dev/null @@ -1,41 +0,0 @@ -BEGIN; - --- ======================= --- DROP NEW ASSOCIATION TABLE --- ======================= -DROP TABLE IF EXISTS letter_incoming_dispositions_department; - --- ======================= --- RESTORE LETTER DISPOSITIONS TABLE STRUCTURE --- ======================= --- Add back the columns that were removed -ALTER TABLE letter_incoming_dispositions ADD COLUMN IF NOT EXISTS from_user_id UUID REFERENCES users(id) ON DELETE SET NULL; -ALTER TABLE letter_incoming_dispositions ADD COLUMN IF NOT EXISTS to_user_id UUID REFERENCES users(id) ON DELETE SET NULL; -ALTER TABLE letter_incoming_dispositions ADD COLUMN IF NOT EXISTS to_department_id UUID REFERENCES departments(id) ON DELETE SET NULL; -ALTER TABLE letter_incoming_dispositions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','read','rejected','completed')); -ALTER TABLE letter_incoming_dispositions ADD COLUMN IF NOT EXISTS completed_at TIMESTAMP WITHOUT TIME ZONE; - --- Rename department_id back to from_department_id -ALTER TABLE letter_incoming_dispositions RENAME COLUMN department_id TO from_department_id; - --- ======================= --- RESTORE TRIGGERS AND INDEXES --- ======================= --- Drop new trigger -DROP TRIGGER IF EXISTS trg_letter_incoming_dispositions_updated_at ON letter_incoming_dispositions; - --- Restore old trigger -CREATE TRIGGER trg_letter_dispositions_updated_at - BEFORE UPDATE ON letter_incoming_dispositions - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- Restore index names -DROP INDEX IF EXISTS idx_letter_incoming_dispositions_letter; -CREATE INDEX IF NOT EXISTS idx_letter_dispositions_letter ON letter_incoming_dispositions(letter_id); - --- ======================= --- RENAME TABLE BACK --- ======================= -ALTER TABLE letter_incoming_dispositions RENAME TO letter_dispositions; - -COMMIT; diff --git a/migrations/000012_rename_dispositions_table.up.sql b/migrations/000012_rename_dispositions_table.up.sql deleted file mode 100644 index 817b822..0000000 --- a/migrations/000012_rename_dispositions_table.up.sql +++ /dev/null @@ -1,54 +0,0 @@ -BEGIN; - --- ======================= --- RENAME LETTER DISPOSITIONS TABLE --- ======================= -ALTER TABLE letter_dispositions RENAME TO letter_incoming_dispositions; - --- ======================= --- MODIFY LETTER INCOMING DISPOSITIONS TABLE STRUCTURE --- ======================= --- Drop existing columns that are not needed -ALTER TABLE letter_incoming_dispositions DROP COLUMN IF EXISTS from_user_id; -ALTER TABLE letter_incoming_dispositions DROP COLUMN IF EXISTS to_user_id; -ALTER TABLE letter_incoming_dispositions DROP COLUMN IF EXISTS to_department_id; -ALTER TABLE letter_incoming_dispositions DROP COLUMN IF EXISTS status; -ALTER TABLE letter_incoming_dispositions DROP COLUMN IF EXISTS completed_at; - --- Rename from_department_id to department_id -ALTER TABLE letter_incoming_dispositions RENAME COLUMN from_department_id TO department_id; - --- Add missing columns if they don't exist -ALTER TABLE letter_incoming_dispositions ADD COLUMN IF NOT EXISTS read_at TIMESTAMP WITHOUT TIME ZONE; -ALTER TABLE letter_incoming_dispositions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP; - --- ======================= --- CREATE LETTER INCOMING DISPOSITIONS DEPARTMENT ASSOCIATION TABLE --- ======================= -CREATE TABLE IF NOT EXISTS letter_incoming_dispositions_department ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_incoming_disposition_id UUID NOT NULL REFERENCES letter_incoming_dispositions(id) ON DELETE CASCADE, - department_id UUID NOT NULL REFERENCES departments(id) ON DELETE CASCADE, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - UNIQUE (letter_incoming_disposition_id, department_id) -); - -CREATE INDEX IF NOT EXISTS idx_letter_incoming_dispositions_department_disposition ON letter_incoming_dispositions_department(letter_incoming_disposition_id); -CREATE INDEX IF NOT EXISTS idx_letter_incoming_dispositions_department_dept ON letter_incoming_dispositions_department(department_id); - --- ======================= --- UPDATE TRIGGERS AND INDEXES --- ======================= --- Drop old trigger -DROP TRIGGER IF EXISTS trg_letter_dispositions_updated_at ON letter_incoming_dispositions; - --- Create new trigger -CREATE TRIGGER trg_letter_incoming_dispositions_updated_at - BEFORE UPDATE ON letter_incoming_dispositions - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- Update index names -DROP INDEX IF EXISTS idx_letter_dispositions_letter; -CREATE INDEX IF NOT EXISTS idx_letter_incoming_dispositions_letter ON letter_incoming_dispositions(letter_id); - -COMMIT; diff --git a/migrations/000013_letters_outgoing_suite.down.sql b/migrations/000013_letters_outgoing_suite.down.sql deleted file mode 100644 index f39276a..0000000 --- a/migrations/000013_letters_outgoing_suite.down.sql +++ /dev/null @@ -1,24 +0,0 @@ -BEGIN; - --- Drop triggers first -DROP TRIGGER IF EXISTS trg_letter_outgoing_discussions_updated_at ON letter_outgoing_discussions; -DROP TRIGGER IF EXISTS trg_approval_flow_steps_updated_at ON approval_flow_steps; -DROP TRIGGER IF EXISTS trg_approval_flows_updated_at ON approval_flows; -DROP TRIGGER IF EXISTS trg_letters_outgoing_updated_at ON letters_outgoing; - --- Drop tables in reverse order (due to foreign key constraints) -DROP TABLE IF EXISTS letter_outgoing_activity_logs; -DROP TABLE IF EXISTS letter_outgoing_discussion_attachments; -DROP TABLE IF EXISTS letter_outgoing_discussions; -DROP TABLE IF EXISTS letter_outgoing_approvals; -DROP TABLE IF EXISTS letter_outgoing_attachments; -DROP TABLE IF EXISTS letter_outgoing_labels; -DROP TABLE IF EXISTS letter_outgoing_recipients; -DROP TABLE IF EXISTS letters_outgoing; -DROP TABLE IF EXISTS approval_flow_steps; -DROP TABLE IF EXISTS approval_flows; - --- Drop sequence -DROP SEQUENCE IF EXISTS letters_outgoing_seq; - -COMMIT; \ No newline at end of file diff --git a/migrations/000013_letters_outgoing_suite.up.sql b/migrations/000013_letters_outgoing_suite.up.sql deleted file mode 100644 index b066c5a..0000000 --- a/migrations/000013_letters_outgoing_suite.up.sql +++ /dev/null @@ -1,199 +0,0 @@ -BEGIN; - --- ======================= --- SEQUENCE FOR LETTER NUMBER --- ======================= -CREATE SEQUENCE IF NOT EXISTS letters_outgoing_seq; - --- ======================= --- APPROVAL FLOWS --- ======================= -CREATE TABLE IF NOT EXISTS approval_flows ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - department_id UUID NOT NULL REFERENCES departments(id) ON DELETE RESTRICT, - name TEXT NOT NULL, - description TEXT, - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_approval_flows_department ON approval_flows(department_id); -CREATE INDEX IF NOT EXISTS idx_approval_flows_active ON approval_flows(is_active); - -CREATE TRIGGER trg_approval_flows_updated_at - BEFORE UPDATE ON approval_flows - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- APPROVAL FLOW STEPS --- ======================= -CREATE TABLE IF NOT EXISTS approval_flow_steps ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - flow_id UUID NOT NULL REFERENCES approval_flows(id) ON DELETE CASCADE, - step_order INT NOT NULL, - parallel_group INT DEFAULT 1, - approver_role_id UUID REFERENCES roles(id) ON DELETE SET NULL, - approver_user_id UUID REFERENCES users(id) ON DELETE SET NULL, - required BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - UNIQUE(flow_id, step_order, parallel_group, approver_role_id, approver_user_id), - CHECK ((approver_role_id IS NOT NULL) OR (approver_user_id IS NOT NULL)) -); - -CREATE INDEX IF NOT EXISTS idx_approval_flow_steps_flow ON approval_flow_steps(flow_id); -CREATE INDEX IF NOT EXISTS idx_approval_flow_steps_order ON approval_flow_steps(flow_id, step_order); - -CREATE TRIGGER trg_approval_flow_steps_updated_at - BEFORE UPDATE ON approval_flow_steps - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- LETTERS OUTGOING --- ======================= -CREATE TABLE IF NOT EXISTS letters_outgoing ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_number TEXT NOT NULL UNIQUE DEFAULT ('OUT-' || lpad(nextval('letters_outgoing_seq')::text, 8, '0')), - reference_number TEXT, - subject TEXT NOT NULL, - description TEXT, - priority_id UUID REFERENCES priorities(id) ON DELETE SET NULL, - receiver_institution_id UUID REFERENCES institutions(id) ON DELETE SET NULL, - issue_date DATE NOT NULL, - status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','pending_approval','approved','sent','archived')), - approval_flow_id UUID REFERENCES approval_flows(id) ON DELETE SET NULL, - created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP WITHOUT TIME ZONE -); - -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_status ON letters_outgoing(status); -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_issue_date ON letters_outgoing(issue_date); -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_approval_flow ON letters_outgoing(approval_flow_id); - -CREATE TRIGGER trg_letters_outgoing_updated_at - BEFORE UPDATE ON letters_outgoing - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- LETTER OUTGOING RECIPIENTS --- ======================= -CREATE TABLE IF NOT EXISTS letter_outgoing_recipients ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_outgoing(id) ON DELETE CASCADE, - recipient_name TEXT NOT NULL, - recipient_email TEXT, - recipient_position TEXT, - recipient_institution TEXT, - is_primary BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_letter ON letter_outgoing_recipients(letter_id); - --- ======================= --- LETTER OUTGOING LABELS (M:N) --- ======================= -CREATE TABLE IF NOT EXISTS letter_outgoing_labels ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_outgoing(id) ON DELETE CASCADE, - label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - UNIQUE (letter_id, label_id) -); - -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_labels_letter ON letter_outgoing_labels(letter_id); - --- ======================= --- LETTER OUTGOING ATTACHMENTS --- ======================= -CREATE TABLE IF NOT EXISTS letter_outgoing_attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_outgoing(id) ON DELETE CASCADE, - file_url TEXT NOT NULL, - file_name TEXT NOT NULL, - file_type TEXT NOT NULL, - uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL, - uploaded_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_attachments_letter ON letter_outgoing_attachments(letter_id); - --- ======================= --- LETTER OUTGOING APPROVALS --- ======================= -CREATE TABLE IF NOT EXISTS letter_outgoing_approvals ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_outgoing(id) ON DELETE CASCADE, - step_id UUID NOT NULL REFERENCES approval_flow_steps(id) ON DELETE CASCADE, - approver_id UUID REFERENCES users(id) ON DELETE SET NULL, - status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')), - remarks TEXT, - acted_at TIMESTAMP WITHOUT TIME ZONE, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_approvals_letter ON letter_outgoing_approvals(letter_id); -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_approvals_step ON letter_outgoing_approvals(step_id); -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_approvals_status ON letter_outgoing_approvals(status); - --- ======================= --- LETTER OUTGOING DISCUSSIONS (Threaded) --- ======================= -CREATE TABLE IF NOT EXISTS letter_outgoing_discussions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_outgoing(id) ON DELETE CASCADE, - parent_id UUID REFERENCES letter_outgoing_discussions(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - message TEXT NOT NULL, - mentions JSONB, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - edited_at TIMESTAMP WITHOUT TIME ZONE -); - -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_discussions_letter ON letter_outgoing_discussions(letter_id); -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_discussions_parent ON letter_outgoing_discussions(parent_id); - -CREATE TRIGGER trg_letter_outgoing_discussions_updated_at - BEFORE UPDATE ON letter_outgoing_discussions - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- ======================= --- LETTER OUTGOING DISCUSSION ATTACHMENTS --- ======================= -CREATE TABLE IF NOT EXISTS letter_outgoing_discussion_attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - discussion_id UUID NOT NULL REFERENCES letter_outgoing_discussions(id) ON DELETE CASCADE, - file_url TEXT NOT NULL, - file_name TEXT NOT NULL, - file_type TEXT NOT NULL, - uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL, - uploaded_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_discussion_attachments_discussion ON letter_outgoing_discussion_attachments(discussion_id); - --- ======================= --- LETTER OUTGOING ACTIVITY LOGS (Immutable) --- ======================= -CREATE TABLE IF NOT EXISTS letter_outgoing_activity_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_outgoing(id) ON DELETE CASCADE, - action_type TEXT NOT NULL, - actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL, - actor_department_id UUID REFERENCES departments(id) ON DELETE SET NULL, - target_type TEXT, - target_id UUID, - from_status TEXT, - to_status TEXT, - context JSONB, - occurred_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_activity_logs_letter ON letter_outgoing_activity_logs(letter_id); -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_activity_logs_action ON letter_outgoing_activity_logs(action_type); - -COMMIT; \ No newline at end of file diff --git a/migrations/000014_modules_and_permissions_update.down.sql b/migrations/000014_modules_and_permissions_update.down.sql deleted file mode 100644 index 1e4f720..0000000 --- a/migrations/000014_modules_and_permissions_update.down.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Rollback modules and permissions update - --- Remove the new structured permissions -DELETE FROM permissions WHERE code LIKE '%_READ' OR code LIKE '%_WRITE' OR code LIKE '%_CREATE' OR code LIKE '%_DELETE'; - --- Drop indexes -DROP INDEX IF EXISTS idx_permissions_module_id; - --- Remove columns from permissions table -ALTER TABLE permissions - DROP COLUMN IF EXISTS module_id, - DROP COLUMN IF EXISTS action; - --- Drop modules table -DROP TABLE IF EXISTS modules CASCADE; \ No newline at end of file diff --git a/migrations/000014_modules_and_permissions_update.up.sql b/migrations/000014_modules_and_permissions_update.up.sql deleted file mode 100644 index 7b29850..0000000 --- a/migrations/000014_modules_and_permissions_update.up.sql +++ /dev/null @@ -1,65 +0,0 @@ --- Add modules table and update permissions structure - --- Create modules table -CREATE TABLE IF NOT EXISTS modules ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - code TEXT UNIQUE NOT NULL, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TRIGGER trg_modules_updated_at - BEFORE UPDATE ON modules - FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- Add module_id and action columns to permissions table -ALTER TABLE permissions - ADD COLUMN IF NOT EXISTS module_id UUID REFERENCES modules(id) ON DELETE CASCADE, - ADD COLUMN IF NOT EXISTS action TEXT; - --- Create index on module_id for better query performance -CREATE INDEX IF NOT EXISTS idx_permissions_module_id ON permissions(module_id); - --- Seed initial modules -INSERT INTO modules (name, code) VALUES - ('User Management', 'USER_MANAGEMENT'), - ('Content Management', 'CONTENT_MANAGEMENT'), - ('Letter Management', 'LETTER_MANAGEMENT'), - ('Disposition Management', 'DISPOSITION_MANAGEMENT'), - ('Reporting', 'REPORTING'), - ('Settings', 'SETTINGS') -ON CONFLICT (code) DO NOTHING; - --- Update existing permissions to include module_id and action --- This is a sample mapping - adjust based on your existing permission codes -UPDATE permissions SET - module_id = (SELECT id FROM modules WHERE code = 'LETTER_MANAGEMENT'), - action = 'READ' -WHERE code LIKE 'letter.%' AND code LIKE '%.view'; - -UPDATE permissions SET - module_id = (SELECT id FROM modules WHERE code = 'LETTER_MANAGEMENT'), - action = 'WRITE' -WHERE code LIKE 'letter.%' AND code LIKE '%.edit'; - -UPDATE permissions SET - module_id = (SELECT id FROM modules WHERE code = 'LETTER_MANAGEMENT'), - action = 'CREATE' -WHERE code LIKE 'letter.%' AND code LIKE '%.create'; - -UPDATE permissions SET - module_id = (SELECT id FROM modules WHERE code = 'LETTER_MANAGEMENT'), - action = 'DELETE' -WHERE code LIKE 'letter.%' AND code LIKE '%.delete'; - --- Insert new structured permissions for each module -INSERT INTO permissions (module_id, action, code, description) -SELECT - m.id, - a.action, - CONCAT(m.code, '_', a.action), - CONCAT('Can ', LOWER(a.action), ' ', LOWER(m.name)) -FROM modules m -CROSS JOIN (VALUES ('READ'), ('WRITE'), ('CREATE'), ('DELETE')) AS a(action) -ON CONFLICT (code) DO NOTHING; \ No newline at end of file diff --git a/migrations/000020_update_letter_outgoing_recipients.down.sql b/migrations/000020_update_letter_outgoing_recipients.down.sql deleted file mode 100644 index 431815e..0000000 --- a/migrations/000020_update_letter_outgoing_recipients.down.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Revert changes to letter_outgoing_recipients table -ALTER TABLE letter_outgoing_recipients -DROP COLUMN IF EXISTS user_id, -DROP COLUMN IF EXISTS department_id, -DROP COLUMN IF EXISTS status, -DROP COLUMN IF EXISTS read_at, -DROP COLUMN IF EXISTS flag, -DROP COLUMN IF EXISTS is_archived; - --- Drop indexes -DROP INDEX IF EXISTS idx_letter_outgoing_recipients_user; -DROP INDEX IF EXISTS idx_letter_outgoing_recipients_department; -DROP INDEX IF EXISTS idx_letter_outgoing_recipients_status; -DROP INDEX IF EXISTS idx_letter_outgoing_recipients_archived; \ No newline at end of file diff --git a/migrations/000020_update_letter_outgoing_recipients.up.sql b/migrations/000020_update_letter_outgoing_recipients.up.sql deleted file mode 100644 index 4e10180..0000000 --- a/migrations/000020_update_letter_outgoing_recipients.up.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Add missing fields to letter_outgoing_recipients table to match incoming letter structure -ALTER TABLE letter_outgoing_recipients -ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id), -ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id), -ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'pending', -ADD COLUMN IF NOT EXISTS read_at TIMESTAMP WITHOUT TIME ZONE, -ADD COLUMN IF NOT EXISTS flag TEXT, -ADD COLUMN IF NOT EXISTS is_archived BOOLEAN DEFAULT false; - --- Add indexes for better query performance -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_user ON letter_outgoing_recipients(user_id); -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_department ON letter_outgoing_recipients(department_id); -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_status ON letter_outgoing_recipients(status); -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_archived ON letter_outgoing_recipients(is_archived); \ No newline at end of file diff --git a/migrations/000021_update_letter_outgoing_recipients.down.sql b/migrations/000021_update_letter_outgoing_recipients.down.sql deleted file mode 100644 index 62532c7..0000000 --- a/migrations/000021_update_letter_outgoing_recipients.down.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE letter_outgoing_recipients - ADD COLUMN IF NOT EXISTS recipient_name VARCHAR(255) NOT NULL, - ADD COLUMN IF NOT EXISTS recipient_email VARCHAR(255) NOT NULL, - ADD COLUMN IF NOT EXISTS recipient_position VARCHAR(255) NOT NULL; \ No newline at end of file diff --git a/migrations/000021_update_letter_outgoing_recipients.up.sql b/migrations/000021_update_letter_outgoing_recipients.up.sql deleted file mode 100644 index 91f1f64..0000000 --- a/migrations/000021_update_letter_outgoing_recipients.up.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE letter_outgoing_recipients - DROP COLUMN IF EXISTS recipient_name, - DROP COLUMN IF EXISTS recipient_email, - DROP COLUMN IF EXISTS recipient_position; \ No newline at end of file diff --git a/migrations/000022_create_document_sessions.down.sql b/migrations/000022_create_document_sessions.down.sql deleted file mode 100644 index 179cd2d..0000000 --- a/migrations/000022_create_document_sessions.down.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Drop triggers -DROP TRIGGER IF EXISTS update_document_sessions_updated_at ON document_sessions; -DROP TRIGGER IF EXISTS update_document_metadata_updated_at ON document_metadata; - --- Drop function -DROP FUNCTION IF EXISTS update_updated_at_column(); - --- Drop tables -DROP TABLE IF EXISTS document_errors; -DROP TABLE IF EXISTS document_metadata; -DROP TABLE IF EXISTS document_versions; -DROP TABLE IF EXISTS document_sessions; \ No newline at end of file diff --git a/migrations/000022_create_document_sessions.up.sql b/migrations/000022_create_document_sessions.up.sql deleted file mode 100644 index 2a62216..0000000 --- a/migrations/000022_create_document_sessions.up.sql +++ /dev/null @@ -1,91 +0,0 @@ --- Create document sessions table for OnlyOffice integration -CREATE TABLE IF NOT EXISTS document_sessions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - document_id UUID NOT NULL, - document_key VARCHAR(255) UNIQUE NOT NULL, - user_id UUID NOT NULL REFERENCES users(id), - status INTEGER NOT NULL DEFAULT 0, - is_locked BOOLEAN DEFAULT false, - locked_by UUID REFERENCES users(id), - locked_at TIMESTAMP WITHOUT TIME ZONE, - last_saved_at TIMESTAMP WITHOUT TIME ZONE, - version INTEGER NOT NULL DEFAULT 1, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - --- Create indexes for document sessions -CREATE INDEX idx_document_sessions_document ON document_sessions(document_id); -CREATE INDEX idx_document_sessions_user ON document_sessions(user_id); -CREATE INDEX idx_document_sessions_status ON document_sessions(status); -CREATE INDEX idx_document_sessions_locked ON document_sessions(is_locked); - --- Create document versions table -CREATE TABLE IF NOT EXISTS document_versions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - document_id UUID NOT NULL, - version INTEGER NOT NULL, - file_url TEXT NOT NULL, - file_size BIGINT NOT NULL, - changes_url TEXT, - saved_by UUID NOT NULL REFERENCES users(id), - saved_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, - is_active BOOLEAN DEFAULT false, - comments TEXT, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - --- Create indexes for document versions -CREATE INDEX idx_document_versions_document ON document_versions(document_id); -CREATE INDEX idx_document_versions_active ON document_versions(is_active); -CREATE UNIQUE INDEX idx_document_versions_active_unique ON document_versions(document_id, is_active) WHERE is_active = true; - --- Create document metadata table -CREATE TABLE IF NOT EXISTS document_metadata ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - document_id UUID UNIQUE NOT NULL, - document_type VARCHAR(50) NOT NULL, - reference_id UUID NOT NULL, - file_name TEXT NOT NULL, - file_type VARCHAR(50) NOT NULL, - file_size BIGINT NOT NULL, - mime_type VARCHAR(255), - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - --- Create indexes for document metadata -CREATE INDEX idx_document_metadata_document ON document_metadata(document_id); -CREATE INDEX idx_document_metadata_type ON document_metadata(document_type); -CREATE INDEX idx_document_metadata_reference ON document_metadata(reference_id); - --- Create document errors table for logging -CREATE TABLE IF NOT EXISTS document_errors ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - document_id UUID NOT NULL, - session_id UUID REFERENCES document_sessions(id), - error_type VARCHAR(100) NOT NULL, - error_msg TEXT NOT NULL, - details JSONB, - created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - --- Create indexes for document errors -CREATE INDEX idx_document_errors_document ON document_errors(document_id); -CREATE INDEX idx_document_errors_session ON document_errors(session_id); -CREATE INDEX idx_document_errors_type ON document_errors(error_type); - --- Add trigger to update updated_at timestamp -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ language 'plpgsql'; - -CREATE TRIGGER update_document_sessions_updated_at BEFORE UPDATE ON document_sessions - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_document_metadata_updated_at BEFORE UPDATE ON document_metadata - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/migrations/000023_add_performance_indexes.down.sql b/migrations/000023_add_performance_indexes.down.sql deleted file mode 100644 index a50f5fb..0000000 --- a/migrations/000023_add_performance_indexes.down.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Drop performance indexes - -DROP INDEX IF EXISTS idx_letters_outgoing_id_deleted; -DROP INDEX IF EXISTS idx_users_id; -DROP INDEX IF EXISTS idx_document_sessions_document_key; -DROP INDEX IF EXISTS idx_document_metadata_document_id; -DROP INDEX IF EXISTS idx_letter_outgoing_attachments_id; -DROP INDEX IF EXISTS idx_letter_outgoing_attachments_letter_id; -DROP INDEX IF EXISTS idx_letter_incoming_attachments_id; -DROP INDEX IF EXISTS idx_letter_incoming_attachments_letter_id; -DROP INDEX IF EXISTS idx_letter_outgoing_discussion_attachments_id; -DROP INDEX IF EXISTS idx_letter_outgoing_discussion_attachments_discussion_id; \ No newline at end of file diff --git a/migrations/000023_add_performance_indexes.up.sql b/migrations/000023_add_performance_indexes.up.sql deleted file mode 100644 index 5143a12..0000000 --- a/migrations/000023_add_performance_indexes.up.sql +++ /dev/null @@ -1,22 +0,0 @@ --- Add indexes to improve query performance - --- Index for letters_outgoing -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_id_deleted ON letters_outgoing(id, deleted_at); - --- Index for users table -CREATE INDEX IF NOT EXISTS idx_users_id ON users(id); - --- Indexes for document sessions lookups -CREATE INDEX IF NOT EXISTS idx_document_sessions_document_key ON document_sessions(document_key); -CREATE INDEX IF NOT EXISTS idx_document_metadata_document_id ON document_metadata(document_id); - --- Additional indexes for letter attachments -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_attachments_id ON letter_outgoing_attachments(id); -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_attachments_letter_id ON letter_outgoing_attachments(letter_id); - -CREATE INDEX IF NOT EXISTS idx_letter_incoming_attachments_id ON letter_incoming_attachments(id); -CREATE INDEX IF NOT EXISTS idx_letter_incoming_attachments_letter_id ON letter_incoming_attachments(letter_id); - --- Index for discussion attachments -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_discussion_attachments_id ON letter_outgoing_discussion_attachments(id); -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_discussion_attachments_discussion_id ON letter_outgoing_discussion_attachments(discussion_id); \ No newline at end of file diff --git a/migrations/000024_fix_document_metadata_constraints.down.sql b/migrations/000024_fix_document_metadata_constraints.down.sql deleted file mode 100644 index 42c010b..0000000 --- a/migrations/000024_fix_document_metadata_constraints.down.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Revert document metadata VARCHAR constraints --- Note: This may fail if existing data exceeds 50 characters - -ALTER TABLE document_metadata - ALTER COLUMN document_type TYPE VARCHAR(50), - ALTER COLUMN file_type TYPE VARCHAR(50); - -ALTER TABLE document_metadata - ALTER COLUMN mime_type TYPE VARCHAR(255); \ No newline at end of file diff --git a/migrations/000024_fix_document_metadata_constraints.up.sql b/migrations/000024_fix_document_metadata_constraints.up.sql deleted file mode 100644 index cd80d9d..0000000 --- a/migrations/000024_fix_document_metadata_constraints.up.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Fix document metadata VARCHAR constraints --- Increase the size limit for document_type and file_type columns - -ALTER TABLE document_metadata - ALTER COLUMN document_type TYPE VARCHAR(255), - ALTER COLUMN file_type TYPE VARCHAR(255); - --- Also ensure mime_type has sufficient length -ALTER TABLE document_metadata - ALTER COLUMN mime_type TYPE VARCHAR(255); \ No newline at end of file diff --git a/migrations/000025_add_not_started_approval_status.down.sql b/migrations/000025_add_not_started_approval_status.down.sql deleted file mode 100644 index 0c71630..0000000 --- a/migrations/000025_add_not_started_approval_status.down.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Revert to original status check constraint without 'not_started' --- First update any 'not_started' statuses to 'pending' -UPDATE letter_outgoing_approvals -SET status = 'pending' -WHERE status = 'not_started'; - --- Drop and recreate the constraint -ALTER TABLE letter_outgoing_approvals -DROP CONSTRAINT IF EXISTS letter_outgoing_approvals_status_check; - -ALTER TABLE letter_outgoing_approvals -ADD CONSTRAINT letter_outgoing_approvals_status_check -CHECK (status IN ('pending', 'approved', 'rejected')); \ No newline at end of file diff --git a/migrations/000025_add_not_started_approval_status.up.sql b/migrations/000025_add_not_started_approval_status.up.sql deleted file mode 100644 index bae2307..0000000 --- a/migrations/000025_add_not_started_approval_status.up.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Add 'not_started' status to letter_outgoing_approvals status check constraint -ALTER TABLE letter_outgoing_approvals -DROP CONSTRAINT IF EXISTS letter_outgoing_approvals_status_check; - -ALTER TABLE letter_outgoing_approvals -ADD CONSTRAINT letter_outgoing_approvals_status_check -CHECK (status IN ('not_started', 'pending', 'approved', 'rejected')); - --- Update default value for new approvals (optional, keeping 'pending' as default) --- ALTER TABLE letter_outgoing_approvals ALTER COLUMN status SET DEFAULT 'not_started'; \ No newline at end of file diff --git a/migrations/000026_add_approval_step_details.down.sql b/migrations/000026_add_approval_step_details.down.sql deleted file mode 100644 index e2fca3d..0000000 --- a/migrations/000026_add_approval_step_details.down.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Remove indexes -DROP INDEX IF EXISTS idx_letter_outgoing_approvals_step_order; -DROP INDEX IF EXISTS idx_letter_outgoing_approvals_parallel_group; - --- Remove columns -ALTER TABLE letter_outgoing_approvals -DROP COLUMN IF EXISTS step_order, -DROP COLUMN IF EXISTS parallel_group, -DROP COLUMN IF EXISTS is_required; \ No newline at end of file diff --git a/migrations/000026_add_approval_step_details.up.sql b/migrations/000026_add_approval_step_details.up.sql deleted file mode 100644 index e4c997c..0000000 --- a/migrations/000026_add_approval_step_details.up.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Add step details to letter_outgoing_approvals table -ALTER TABLE letter_outgoing_approvals -ADD COLUMN IF NOT EXISTS step_order INT NOT NULL DEFAULT 1, -ADD COLUMN IF NOT EXISTS parallel_group INT DEFAULT 1, -ADD COLUMN IF NOT EXISTS is_required BOOLEAN DEFAULT true; - --- Add indexes for better query performance -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_approvals_step_order -ON letter_outgoing_approvals(letter_id, step_order); - -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_approvals_parallel_group -ON letter_outgoing_approvals(letter_id, parallel_group); - --- Add comments for documentation -COMMENT ON COLUMN letter_outgoing_approvals.step_order IS 'The order in which this approval step should be processed'; -COMMENT ON COLUMN letter_outgoing_approvals.parallel_group IS 'Steps with the same parallel_group value can be processed simultaneously'; -COMMENT ON COLUMN letter_outgoing_approvals.is_required IS 'Whether this approval step is required for the letter to proceed'; \ No newline at end of file diff --git a/migrations/000027_create_analytics_summary_tables.down.sql b/migrations/000027_create_analytics_summary_tables.down.sql deleted file mode 100644 index 0bf8929..0000000 --- a/migrations/000027_create_analytics_summary_tables.down.sql +++ /dev/null @@ -1,23 +0,0 @@ --- Drop triggers -DROP TRIGGER IF EXISTS update_letter_summary_on_incoming ON letters_incoming; -DROP TRIGGER IF EXISTS update_letter_summary_on_outgoing ON letters_outgoing; - --- Drop functions -DROP FUNCTION IF EXISTS update_letter_summary(); -DROP FUNCTION IF EXISTS populate_letter_summary_history(); - --- Drop indexes -DROP INDEX IF EXISTS idx_letter_summary_date; -DROP INDEX IF EXISTS idx_letter_summary_type_date; -DROP INDEX IF EXISTS idx_dept_letter_summary_dept_date; -DROP INDEX IF EXISTS idx_dept_letter_summary_date; -DROP INDEX IF EXISTS idx_inst_letter_summary_inst_date; -DROP INDEX IF EXISTS idx_inst_letter_summary_date; -DROP INDEX IF EXISTS idx_approval_sla_summary_date; -DROP INDEX IF EXISTS idx_approval_sla_summary_dept_date; - --- Drop tables -DROP TABLE IF EXISTS approval_sla_summary; -DROP TABLE IF EXISTS institution_letter_summary; -DROP TABLE IF EXISTS department_letter_summary; -DROP TABLE IF EXISTS letter_summary; \ No newline at end of file diff --git a/migrations/000027_create_analytics_summary_tables.up.sql b/migrations/000027_create_analytics_summary_tables.up.sql deleted file mode 100644 index 8ab2024..0000000 --- a/migrations/000027_create_analytics_summary_tables.up.sql +++ /dev/null @@ -1,389 +0,0 @@ --- Create letter_summary table for daily aggregated statistics -CREATE TABLE IF NOT EXISTS letter_summary ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - summary_date DATE NOT NULL, - letter_type VARCHAR(20) NOT NULL, -- 'incoming' or 'outgoing' - total_count INTEGER DEFAULT 0, - pending_count INTEGER DEFAULT 0, - approved_count INTEGER DEFAULT 0, - rejected_count INTEGER DEFAULT 0, - archived_count INTEGER DEFAULT 0, - sent_count INTEGER DEFAULT 0, - avg_processing_hours DECIMAL(10,2), - min_processing_hours DECIMAL(10,2), - max_processing_hours DECIMAL(10,2), - median_processing_hours DECIMAL(10,2), - p95_processing_hours DECIMAL(10,2), - p99_processing_hours DECIMAL(10,2), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(summary_date, letter_type) -); - --- Create department_letter_summary table -CREATE TABLE IF NOT EXISTS department_letter_summary ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - department_id UUID NOT NULL REFERENCES departments(id), - summary_date DATE NOT NULL, - incoming_count INTEGER DEFAULT 0, - outgoing_count INTEGER DEFAULT 0, - pending_incoming INTEGER DEFAULT 0, - pending_outgoing INTEGER DEFAULT 0, - approved_outgoing INTEGER DEFAULT 0, - rejected_outgoing INTEGER DEFAULT 0, - avg_response_hours DECIMAL(10,2), - completion_rate DECIMAL(5,2), - total_recipients INTEGER DEFAULT 0, - unique_senders INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(department_id, summary_date) -); - --- Create institution_letter_summary table -CREATE TABLE IF NOT EXISTS institution_letter_summary ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - institution_id UUID NOT NULL REFERENCES institutions(id), - summary_date DATE NOT NULL, - incoming_sent INTEGER DEFAULT 0, - outgoing_received INTEGER DEFAULT 0, - total_correspondence INTEGER DEFAULT 0, - avg_turnaround_hours DECIMAL(10,2), - last_activity_at TIMESTAMP, - priority_high_count INTEGER DEFAULT 0, - priority_medium_count INTEGER DEFAULT 0, - priority_low_count INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(institution_id, summary_date) -); - --- Create approval_sla_summary table -CREATE TABLE IF NOT EXISTS approval_sla_summary ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - summary_date DATE NOT NULL, - department_id UUID REFERENCES departments(id), - total_approvals INTEGER DEFAULT 0, - approved_count INTEGER DEFAULT 0, - rejected_count INTEGER DEFAULT 0, - pending_count INTEGER DEFAULT 0, - avg_approval_hours DECIMAL(10,2), - min_approval_hours DECIMAL(10,2), - max_approval_hours DECIMAL(10,2), - median_approval_hours DECIMAL(10,2), - within_sla_count INTEGER DEFAULT 0, - exceeded_sla_count INTEGER DEFAULT 0, - sla_compliance_rate DECIMAL(5,2), - avg_approval_steps DECIMAL(5,2), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(summary_date, department_id) -); - --- Create indexes for better query performance -CREATE INDEX idx_letter_summary_date ON letter_summary(summary_date DESC); -CREATE INDEX idx_letter_summary_type_date ON letter_summary(letter_type, summary_date DESC); - -CREATE INDEX idx_dept_letter_summary_dept_date ON department_letter_summary(department_id, summary_date DESC); -CREATE INDEX idx_dept_letter_summary_date ON department_letter_summary(summary_date DESC); - -CREATE INDEX idx_inst_letter_summary_inst_date ON institution_letter_summary(institution_id, summary_date DESC); -CREATE INDEX idx_inst_letter_summary_date ON institution_letter_summary(summary_date DESC); - -CREATE INDEX idx_approval_sla_summary_date ON approval_sla_summary(summary_date DESC); -CREATE INDEX idx_approval_sla_summary_dept_date ON approval_sla_summary(department_id, summary_date DESC); - --- Create function to update letter_summary -CREATE OR REPLACE FUNCTION update_letter_summary() -RETURNS TRIGGER AS $$ -DECLARE - v_date DATE; - v_type VARCHAR(20); -BEGIN - -- Determine date and type - IF TG_TABLE_NAME = 'letters_incoming' THEN - v_type := 'incoming'; - v_date := DATE(COALESCE(NEW.created_at, OLD.created_at)); - ELSE - v_type := 'outgoing'; - v_date := DATE(COALESCE(NEW.created_at, OLD.created_at)); - END IF; - - -- Update or insert summary - INSERT INTO letter_summary ( - summary_date, - letter_type, - total_count, - pending_count, - approved_count, - rejected_count, - archived_count, - sent_count - ) - SELECT - v_date, - v_type, - COUNT(*) as total_count, - COUNT(*) FILTER (WHERE status = 'pending' OR status = 'pending_approval') as pending_count, - COUNT(*) FILTER (WHERE status = 'approved') as approved_count, - COUNT(*) FILTER (WHERE status = 'rejected') as rejected_count, - COUNT(*) FILTER (WHERE status = 'archived') as archived_count, - COUNT(*) FILTER (WHERE status = 'sent') as sent_count - FROM ( - SELECT status, created_at FROM letters_incoming WHERE DATE(created_at) = v_date AND v_type = 'incoming' - UNION ALL - SELECT status, created_at FROM letters_outgoing WHERE DATE(created_at) = v_date AND v_type = 'outgoing' - ) t - ON CONFLICT (summary_date, letter_type) - DO UPDATE SET - total_count = EXCLUDED.total_count, - pending_count = EXCLUDED.pending_count, - approved_count = EXCLUDED.approved_count, - rejected_count = EXCLUDED.rejected_count, - archived_count = EXCLUDED.archived_count, - sent_count = EXCLUDED.sent_count, - updated_at = CURRENT_TIMESTAMP; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- Create triggers for automatic updates -CREATE TRIGGER update_letter_summary_on_incoming - AFTER INSERT OR UPDATE OR DELETE ON letters_incoming - FOR EACH ROW - EXECUTE FUNCTION update_letter_summary(); - -CREATE TRIGGER update_letter_summary_on_outgoing - AFTER INSERT OR UPDATE OR DELETE ON letters_outgoing - FOR EACH ROW - EXECUTE FUNCTION update_letter_summary(); - --- Function to populate historical data -CREATE OR REPLACE FUNCTION populate_letter_summary_history() -RETURNS void AS $$ -BEGIN - -- Populate letter_summary with historical data - INSERT INTO letter_summary ( - summary_date, - letter_type, - total_count, - pending_count, - approved_count, - rejected_count, - archived_count, - sent_count, - avg_processing_hours - ) - SELECT - DATE(created_at) as summary_date, - 'incoming' as letter_type, - COUNT(*) as total_count, - COUNT(*) FILTER (WHERE status = 'pending') as pending_count, - COUNT(*) FILTER (WHERE status = 'approved') as approved_count, - COUNT(*) FILTER (WHERE status = 'rejected') as rejected_count, - COUNT(*) FILTER (WHERE status = 'archived') as archived_count, - 0 as sent_count, - AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/3600) as avg_processing_hours - FROM letters_incoming - WHERE deleted_at IS NULL - GROUP BY DATE(created_at) - UNION ALL - SELECT - DATE(created_at) as summary_date, - 'outgoing' as letter_type, - COUNT(*) as total_count, - COUNT(*) FILTER (WHERE status = 'pending_approval') as pending_count, - COUNT(*) FILTER (WHERE status = 'approved') as approved_count, - COUNT(*) FILTER (WHERE status = 'rejected') as rejected_count, - COUNT(*) FILTER (WHERE status = 'archived') as archived_count, - COUNT(*) FILTER (WHERE status = 'sent') as sent_count, - AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/3600) as avg_processing_hours - FROM letters_outgoing - WHERE deleted_at IS NULL - GROUP BY DATE(created_at) - ON CONFLICT (summary_date, letter_type) DO NOTHING; - - -- Populate department_letter_summary - INSERT INTO department_letter_summary ( - department_id, - summary_date, - incoming_count, - outgoing_count, - pending_incoming, - pending_outgoing, - approved_outgoing, - rejected_outgoing, - avg_response_hours, - completion_rate - ) - SELECT - d.id as department_id, - dates.summary_date, - COALESCE(inc.count, 0) as incoming_count, - COALESCE(outg.count, 0) as outgoing_count, - COALESCE(inc.pending, 0) as pending_incoming, - COALESCE(outg.pending, 0) as pending_outgoing, - COALESCE(outg.approved, 0) as approved_outgoing, - COALESCE(outg.rejected, 0) as rejected_outgoing, - COALESCE(outg.avg_hours, 0) as avg_response_hours, - CASE - WHEN COALESCE(outg.count, 0) > 0 - THEN (COALESCE(outg.completed, 0)::DECIMAL / outg.count::DECIMAL) * 100 - ELSE 0 - END as completion_rate - FROM departments d - CROSS JOIN ( - SELECT DISTINCT DATE(created_at) as summary_date - FROM letters_incoming - UNION - SELECT DISTINCT DATE(created_at) - FROM letters_outgoing - ) dates - LEFT JOIN ( - SELECT - lir.recipient_department_id as dept_id, - DATE(li.created_at) as date, - COUNT(DISTINCT li.id) as count, - COUNT(DISTINCT li.id) FILTER (WHERE li.status = 'pending') as pending - FROM letters_incoming li - JOIN letter_incoming_recipients lir ON lir.letter_id = li.id - WHERE li.deleted_at IS NULL - GROUP BY lir.recipient_department_id, DATE(li.created_at) - ) inc ON inc.dept_id = d.id AND inc.date = dates.summary_date - LEFT JOIN ( - SELECT - lor.department_id as dept_id, - DATE(lo.created_at) as date, - COUNT(DISTINCT lo.id) as count, - COUNT(DISTINCT lo.id) FILTER (WHERE lo.status = 'pending_approval') as pending, - COUNT(DISTINCT lo.id) FILTER (WHERE lo.status = 'approved') as approved, - COUNT(DISTINCT lo.id) FILTER (WHERE lo.status = 'rejected') as rejected, - COUNT(DISTINCT lo.id) FILTER (WHERE lo.status IN ('sent', 'archived')) as completed, - AVG(EXTRACT(EPOCH FROM (lo.updated_at - lo.created_at))/3600) as avg_hours - FROM letters_outgoing lo - JOIN letter_outgoing_recipients lor ON lor.letter_id = lo.id - WHERE lo.deleted_at IS NULL - GROUP BY lor.department_id, DATE(lo.created_at) - ) outg ON outg.dept_id = d.id AND outg.date = dates.summary_date - WHERE inc.count > 0 OR outg.count > 0 - ON CONFLICT (department_id, summary_date) DO NOTHING; - - -- Populate institution_letter_summary - INSERT INTO institution_letter_summary ( - institution_id, - summary_date, - incoming_sent, - outgoing_received, - total_correspondence, - avg_turnaround_hours, - last_activity_at, - priority_high_count, - priority_medium_count, - priority_low_count - ) - SELECT - i.id as institution_id, - dates.summary_date, - COALESCE(inc.count, 0) as incoming_sent, - COALESCE(outg.count, 0) as outgoing_received, - COALESCE(inc.count, 0) + COALESCE(outg.count, 0) as total_correspondence, - COALESCE((inc.avg_hours + outg.avg_hours) / 2, 0) as avg_turnaround_hours, - GREATEST(inc.last_activity, outg.last_activity) as last_activity_at, - COALESCE(inc.high_priority, 0) + COALESCE(outg.high_priority, 0) as priority_high_count, - COALESCE(inc.medium_priority, 0) + COALESCE(outg.medium_priority, 0) as priority_medium_count, - COALESCE(inc.low_priority, 0) + COALESCE(outg.low_priority, 0) as priority_low_count - FROM institutions i - CROSS JOIN ( - SELECT DISTINCT DATE(created_at) as summary_date - FROM letters_incoming - UNION - SELECT DISTINCT DATE(created_at) - FROM letters_outgoing - ) dates - LEFT JOIN ( - SELECT - sender_institution_id as inst_id, - DATE(created_at) as date, - COUNT(*) as count, - AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/3600) as avg_hours, - MAX(created_at) as last_activity, - COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 1)) as high_priority, - COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 2)) as medium_priority, - COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 3)) as low_priority - FROM letters_incoming - WHERE deleted_at IS NULL AND sender_institution_id IS NOT NULL - GROUP BY sender_institution_id, DATE(created_at) - ) inc ON inc.inst_id = i.id AND inc.date = dates.summary_date - LEFT JOIN ( - SELECT - receiver_institution_id as inst_id, - DATE(created_at) as date, - COUNT(*) as count, - AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/3600) as avg_hours, - MAX(created_at) as last_activity, - COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 1)) as high_priority, - COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 2)) as medium_priority, - COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 3)) as low_priority - FROM letters_outgoing - WHERE deleted_at IS NULL AND receiver_institution_id IS NOT NULL - GROUP BY receiver_institution_id, DATE(created_at) - ) outg ON outg.inst_id = i.id AND outg.date = dates.summary_date - WHERE inc.count > 0 OR outg.count > 0 - ON CONFLICT (institution_id, summary_date) DO NOTHING; - - -- Populate approval_sla_summary - INSERT INTO approval_sla_summary ( - summary_date, - department_id, - total_approvals, - approved_count, - rejected_count, - pending_count, - avg_approval_hours, - min_approval_hours, - max_approval_hours, - median_approval_hours, - within_sla_count, - exceeded_sla_count, - sla_compliance_rate, - avg_approval_steps - ) - SELECT - DATE(loa.created_at) as summary_date, - d.id as department_id, - COUNT(loa.id) as total_approvals, - COUNT(loa.id) FILTER (WHERE loa.status = 'approved') as approved_count, - COUNT(loa.id) FILTER (WHERE loa.status = 'rejected') as rejected_count, - COUNT(loa.id) FILTER (WHERE loa.status = 'pending') as pending_count, - AVG(EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600) FILTER (WHERE loa.acted_at IS NOT NULL) as avg_approval_hours, - MIN(EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600) FILTER (WHERE loa.acted_at IS NOT NULL) as min_approval_hours, - MAX(EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600) FILTER (WHERE loa.acted_at IS NOT NULL) as max_approval_hours, - PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600) FILTER (WHERE loa.acted_at IS NOT NULL) as median_approval_hours, - COUNT(loa.id) FILTER (WHERE EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600 <= 24 AND loa.acted_at IS NOT NULL) as within_sla_count, - COUNT(loa.id) FILTER (WHERE EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600 > 24 AND loa.acted_at IS NOT NULL) as exceeded_sla_count, - CASE - WHEN COUNT(loa.id) FILTER (WHERE loa.acted_at IS NOT NULL) > 0 - THEN (COUNT(loa.id) FILTER (WHERE EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600 <= 24 AND loa.acted_at IS NOT NULL)::DECIMAL / - COUNT(loa.id) FILTER (WHERE loa.acted_at IS NOT NULL)::DECIMAL) * 100 - ELSE 0 - END as sla_compliance_rate, - AVG(step_counts.step_count) as avg_approval_steps - FROM letter_outgoing_approvals loa - JOIN letters_outgoing lo ON lo.id = loa.letter_id - JOIN letter_outgoing_recipients lor ON lor.letter_id = lo.id - JOIN departments d ON d.id = lor.department_id - LEFT JOIN ( - SELECT letter_id, COUNT(*) as step_count - FROM letter_outgoing_approvals - GROUP BY letter_id - ) step_counts ON step_counts.letter_id = loa.letter_id - WHERE lo.deleted_at IS NULL - GROUP BY DATE(loa.created_at), d.id - ON CONFLICT (summary_date, department_id) DO NOTHING; - -END; -$$ LANGUAGE plpgsql; - --- Execute the function to populate historical data -SELECT populate_letter_summary_history(); \ No newline at end of file diff --git a/migrations/000028_add_status_disposition_department.down.sql b/migrations/000028_add_status_disposition_department.down.sql deleted file mode 100644 index 50296b6..0000000 --- a/migrations/000028_add_status_disposition_department.down.sql +++ /dev/null @@ -1,25 +0,0 @@ --- 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 deleted file mode 100644 index 01e806e..0000000 --- a/migrations/000028_add_status_disposition_department.up.sql +++ /dev/null @@ -1,38 +0,0 @@ --- 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 deleted file mode 100644 index 2f40cb4..0000000 --- a/migrations/000029_update_incoming_letter_department_recipients.down.sql +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index fc0ee3d..0000000 --- a/migrations/000029_update_incoming_letter_department_recipients.up.sql +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index ab6c624..0000000 --- a/migrations/000030_add_performance_indexes.down.sql +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index bf119d7..0000000 --- a/migrations/000030_add_performance_indexes.up.sql +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 420a245..0000000 --- a/migrations/000031_add_missing_indexes.down.sql +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 64b8846..0000000 --- a/migrations/000031_add_missing_indexes.up.sql +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index d12c098..0000000 --- a/migrations/000032_add_system_user_and_department.down.sql +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 5494a42..0000000 --- a/migrations/000032_add_system_user_and_department.up.sql +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 70cf053..0000000 --- a/migrations/000033_add_notes_to_disposition_department.down.sql +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 5e0fcfa..0000000 --- a/migrations/000033_add_notes_to_disposition_department.up.sql +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 8b66549..0000000 --- a/migrations/000034_add_archived_indexes.down.sql +++ /dev/null @@ -1,17 +0,0 @@ --- 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 deleted file mode 100644 index 5aa2f21..0000000 --- a/migrations/000034_add_archived_indexes.up.sql +++ /dev/null @@ -1,36 +0,0 @@ --- 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 diff --git a/migrations/000035_add_parent_department_id.down.sql b/migrations/000035_add_parent_department_id.down.sql deleted file mode 100644 index 38ac6b4..0000000 --- a/migrations/000035_add_parent_department_id.down.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Drop the view -DROP VIEW IF EXISTS department_hierarchy; - --- Drop the functions -DROP FUNCTION IF EXISTS get_department_hierarchy_path(UUID); -DROP FUNCTION IF EXISTS check_department_hierarchy(); - --- Drop the trigger -DROP TRIGGER IF EXISTS check_department_hierarchy_trigger ON departments; - --- Drop the index -DROP INDEX IF EXISTS idx_departments_parent_id; - --- Remove the parent_department_id column -ALTER TABLE departments DROP COLUMN IF EXISTS parent_department_id; \ No newline at end of file diff --git a/migrations/000035_add_parent_department_id.up.sql b/migrations/000035_add_parent_department_id.up.sql deleted file mode 100644 index cf67c90..0000000 --- a/migrations/000035_add_parent_department_id.up.sql +++ /dev/null @@ -1,128 +0,0 @@ --- 1) Schema changes -ALTER TABLE departments - ADD COLUMN parent_department_id UUID REFERENCES departments(id) ON DELETE CASCADE; - -CREATE INDEX idx_departments_parent_id ON departments(parent_department_id); - --- (Optional but recommended for ltree lookups) --- CREATE EXTENSION IF NOT EXISTS ltree; --- CREATE INDEX idx_departments_path_gist ON departments USING GIST (path); --- CREATE INDEX idx_departments_path_nlevel ON departments ((nlevel(path))); - --- 2) Migrate parent ids from existing ltree path --- Use ltree helpers: nlevel() and subpath() -UPDATE departments d1 -SET parent_department_id = d2.id - FROM departments d2 -WHERE nlevel(d1.path) > 1 - AND d2.path = subpath(d1.path, 0, nlevel(d1.path) - 1); - --- 3) Guard against cycles/self-parent via trigger -CREATE OR REPLACE FUNCTION check_department_hierarchy() -RETURNS TRIGGER AS $$ -DECLARE -current_id UUID; - max_depth INT := 100; - depth INT := 0; -BEGIN - IF NEW.parent_department_id IS NULL THEN - RETURN NEW; -END IF; - - -- self-reference - IF NEW.parent_department_id = NEW.id THEN - RAISE EXCEPTION 'Department cannot be its own parent'; -END IF; - - -- walk up the tree - current_id := NEW.parent_department_id; - WHILE current_id IS NOT NULL AND depth < max_depth LOOP - IF current_id = NEW.id THEN - RAISE EXCEPTION 'Circular reference detected in department hierarchy'; -END IF; - -SELECT parent_department_id INTO current_id -FROM departments -WHERE id = current_id; - -depth := depth + 1; -END LOOP; - - IF depth >= max_depth THEN - RAISE EXCEPTION 'Department hierarchy too deep (max: %)', max_depth; -END IF; - -RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS check_department_hierarchy_trigger ON departments; - -CREATE TRIGGER check_department_hierarchy_trigger - BEFORE INSERT OR UPDATE OF parent_department_id ON departments - FOR EACH ROW - EXECUTE FUNCTION check_department_hierarchy(); - --- 4) Helper to rebuild dotted text path from codes (top.down.leaf) -CREATE OR REPLACE FUNCTION get_department_hierarchy_path(dept_id UUID) -RETURNS TEXT AS $$ -DECLARE -path_text TEXT := ''; - current_dept RECORD; - current_id UUID := dept_id; -BEGIN - WHILE current_id IS NOT NULL LOOP -SELECT id, name, parent_department_id, code -INTO current_dept -FROM departments -WHERE id = current_id; - -IF current_dept IS NULL THEN - EXIT; -END IF; - - IF path_text = '' THEN - path_text := current_dept.code; -ELSE - path_text := current_dept.code || '.' || path_text; -END IF; - - current_id := current_dept.parent_department_id; -END LOOP; - -RETURN path_text; -END; -$$ LANGUAGE plpgsql; - --- 5) View for easy hierarchy queries -CREATE OR REPLACE VIEW department_hierarchy AS -WITH RECURSIVE dept_tree AS ( - -- roots - SELECT - id, - name, - code, - parent_department_id, - path, - 0 AS level, - ARRAY[id] AS hierarchy_ids, - ARRAY[name] AS hierarchy_names - FROM departments - WHERE parent_department_id IS NULL - - UNION ALL - - -- children - SELECT - d.id, - d.name, - d.code, - d.parent_department_id, - d.path, - dt.level + 1, - dt.hierarchy_ids || d.id, - dt.hierarchy_names || d.name - FROM departments d - JOIN dept_tree dt ON d.parent_department_id = dt.id -) -SELECT * FROM dept_tree; diff --git a/migrations/000036_add_search_indexes.down.sql b/migrations/000036_add_search_indexes.down.sql deleted file mode 100644 index 7dbba09..0000000 --- a/migrations/000036_add_search_indexes.down.sql +++ /dev/null @@ -1,31 +0,0 @@ --- Drop functions -DROP FUNCTION IF EXISTS search_outgoing_letters(text); -DROP FUNCTION IF EXISTS search_incoming_letters(text); - --- Drop indexes for outgoing letters -DROP INDEX IF EXISTS idx_letters_outgoing_letter_number_text; -DROP INDEX IF EXISTS idx_letters_outgoing_subject_text; -DROP INDEX IF EXISTS idx_letters_outgoing_description_text; -DROP INDEX IF EXISTS idx_letters_outgoing_reference_number_text; -DROP INDEX IF EXISTS idx_letters_outgoing_status_created; -DROP INDEX IF EXISTS idx_letters_outgoing_priority_created; -DROP INDEX IF EXISTS idx_letters_outgoing_institution_created; -DROP INDEX IF EXISTS idx_letters_outgoing_created_by; -DROP INDEX IF EXISTS idx_letters_outgoing_issue_date; - --- Drop indexes for incoming letters -DROP INDEX IF EXISTS idx_letters_incoming_letter_number_text; -DROP INDEX IF EXISTS idx_letters_incoming_subject_text; -DROP INDEX IF EXISTS idx_letters_incoming_description_text; -DROP INDEX IF EXISTS idx_letters_incoming_reference_number_text; -DROP INDEX IF EXISTS idx_letters_incoming_status_created; -DROP INDEX IF EXISTS idx_letters_incoming_priority_created; -DROP INDEX IF EXISTS idx_letters_incoming_institution_created; -DROP INDEX IF EXISTS idx_letters_incoming_created_by; -DROP INDEX IF EXISTS idx_letters_incoming_received_date; - --- Drop recipient indexes -DROP INDEX IF EXISTS idx_letter_outgoing_recipients_user; -DROP INDEX IF EXISTS idx_letter_outgoing_recipients_dept; -DROP INDEX IF EXISTS idx_letter_incoming_recipients_user; -DROP INDEX IF EXISTS idx_letter_incoming_recipients_dept; \ No newline at end of file diff --git a/migrations/000036_add_search_indexes.up.sql b/migrations/000036_add_search_indexes.up.sql deleted file mode 100644 index adcf49a..0000000 --- a/migrations/000036_add_search_indexes.up.sql +++ /dev/null @@ -1,63 +0,0 @@ --- Add indexes for optimized search on outgoing letters -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_letter_number_text ON letters_outgoing USING gin(to_tsvector('simple', letter_number)); -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_subject_text ON letters_outgoing USING gin(to_tsvector('simple', subject)); -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_description_text ON letters_outgoing USING gin(to_tsvector('simple', COALESCE(description, ''))); -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_reference_number_text ON letters_outgoing USING gin(to_tsvector('simple', COALESCE(reference_number, ''))); - --- Composite indexes for common query patterns on outgoing letters -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_status_created ON letters_outgoing(status, created_at DESC) WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_priority_created ON letters_outgoing(priority_id, created_at DESC) WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_institution_created ON letters_outgoing(receiver_institution_id, created_at DESC) WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_created_by ON letters_outgoing(created_by, created_at DESC) WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_issue_date ON letters_outgoing(issue_date DESC) WHERE deleted_at IS NULL; - --- Add indexes for optimized search on incoming letters -CREATE INDEX IF NOT EXISTS idx_letters_incoming_letter_number_text ON letters_incoming USING gin(to_tsvector('simple', letter_number)); -CREATE INDEX IF NOT EXISTS idx_letters_incoming_subject_text ON letters_incoming USING gin(to_tsvector('simple', subject)); -CREATE INDEX IF NOT EXISTS idx_letters_incoming_description_text ON letters_incoming USING gin(to_tsvector('simple', COALESCE(description, ''))); -CREATE INDEX IF NOT EXISTS idx_letters_incoming_reference_number_text ON letters_incoming USING gin(to_tsvector('simple', COALESCE(reference_number, ''))); - --- Composite indexes for common query patterns on incoming letters -CREATE INDEX IF NOT EXISTS idx_letters_incoming_status_created ON letters_incoming(status, created_at DESC) WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS idx_letters_incoming_priority_created ON letters_incoming(priority_id, created_at DESC) WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS idx_letters_incoming_institution_created ON letters_incoming(sender_institution_id, created_at DESC) WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS idx_letters_incoming_created_by ON letters_incoming(created_by, created_at DESC) WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS idx_letters_incoming_received_date ON letters_incoming(received_date DESC) WHERE deleted_at IS NULL; - --- Indexes for recipient lookups -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_user ON letter_outgoing_recipients(user_id, letter_id); -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_dept ON letter_outgoing_recipients(department_id, letter_id); -CREATE INDEX IF NOT EXISTS idx_letter_incoming_recipients_user ON letter_incoming_recipients(recipient_user_id, letter_id); -CREATE INDEX IF NOT EXISTS idx_letter_incoming_recipients_dept ON letter_incoming_recipients(recipient_department_id, letter_id); - --- Create a function for full-text search on outgoing letters -CREATE OR REPLACE FUNCTION search_outgoing_letters(search_query text) -RETURNS SETOF letters_outgoing AS $$ -BEGIN - RETURN QUERY - SELECT * FROM letters_outgoing - WHERE deleted_at IS NULL - AND ( - to_tsvector('simple', letter_number) @@ plainto_tsquery('simple', search_query) - OR to_tsvector('simple', subject) @@ plainto_tsquery('simple', search_query) - OR to_tsvector('simple', COALESCE(description, '')) @@ plainto_tsquery('simple', search_query) - OR to_tsvector('simple', COALESCE(reference_number, '')) @@ plainto_tsquery('simple', search_query) - ); -END; -$$ LANGUAGE plpgsql; - --- Create a function for full-text search on incoming letters -CREATE OR REPLACE FUNCTION search_incoming_letters(search_query text) -RETURNS SETOF letters_incoming AS $$ -BEGIN - RETURN QUERY - SELECT * FROM letters_incoming - WHERE deleted_at IS NULL - AND ( - to_tsvector('simple', letter_number) @@ plainto_tsquery('simple', search_query) - OR to_tsvector('simple', subject) @@ plainto_tsquery('simple', search_query) - OR to_tsvector('simple', COALESCE(description, '')) @@ plainto_tsquery('simple', search_query) - OR to_tsvector('simple', COALESCE(reference_number, '')) @@ plainto_tsquery('simple', search_query) - ); -END; -$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/migrations/000037_add_sender_receiver_names.down.sql b/migrations/000037_add_sender_receiver_names.down.sql deleted file mode 100644 index 85c4676..0000000 --- a/migrations/000037_add_sender_receiver_names.down.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Drop indexes -DROP INDEX IF EXISTS idx_letters_incoming_sender_name_text; -DROP INDEX IF EXISTS idx_letters_outgoing_receiver_name_text; -DROP INDEX IF EXISTS idx_letters_incoming_sender_name; -DROP INDEX IF EXISTS idx_letters_outgoing_receiver_name; - --- Remove columns -ALTER TABLE letters_incoming -DROP COLUMN IF EXISTS sender_name; - -ALTER TABLE letters_outgoing -DROP COLUMN IF EXISTS receiver_name; \ No newline at end of file diff --git a/migrations/000037_add_sender_receiver_names.up.sql b/migrations/000037_add_sender_receiver_names.up.sql deleted file mode 100644 index 3dca968..0000000 --- a/migrations/000037_add_sender_receiver_names.up.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Add sender_name to incoming letters -ALTER TABLE letters_incoming -ADD COLUMN IF NOT EXISTS sender_name VARCHAR(255); - --- Add receiver_name to outgoing letters -ALTER TABLE letters_outgoing -ADD COLUMN IF NOT EXISTS receiver_name VARCHAR(255); - --- Add indexes for the new fields to support searching -CREATE INDEX IF NOT EXISTS idx_letters_incoming_sender_name ON letters_incoming(sender_name) WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_receiver_name ON letters_outgoing(receiver_name) WHERE deleted_at IS NULL; - --- Add GIN indexes for full-text search -CREATE INDEX IF NOT EXISTS idx_letters_incoming_sender_name_text ON letters_incoming USING gin(to_tsvector('simple', COALESCE(sender_name, ''))); -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_receiver_name_text ON letters_outgoing USING gin(to_tsvector('simple', COALESCE(receiver_name, ''))); \ No newline at end of file diff --git a/migrations/000038_add_type_to_letter_incoming.down.sql b/migrations/000038_add_type_to_letter_incoming.down.sql deleted file mode 100644 index 74f13a1..0000000 --- a/migrations/000038_add_type_to_letter_incoming.down.sql +++ /dev/null @@ -1,9 +0,0 @@ -BEGIN; - --- Drop index -DROP INDEX IF EXISTS idx_letters_incoming_type; - --- Remove type column from letters_incoming table -ALTER TABLE letters_incoming DROP COLUMN IF EXISTS type; - -COMMIT; \ No newline at end of file diff --git a/migrations/000038_add_type_to_letter_incoming.up.sql b/migrations/000038_add_type_to_letter_incoming.up.sql deleted file mode 100644 index 272c09b..0000000 --- a/migrations/000038_add_type_to_letter_incoming.up.sql +++ /dev/null @@ -1,11 +0,0 @@ -BEGIN; - --- Add type column to letters_incoming table -ALTER TABLE letters_incoming -ADD COLUMN IF NOT EXISTS type TEXT DEFAULT 'UTAMA' -CHECK (type IN ('UTAMA', 'TEMBUSAN')); - --- Add index for type column for better query performance -CREATE INDEX IF NOT EXISTS idx_letters_incoming_type ON letters_incoming(type); - -COMMIT; \ No newline at end of file diff --git a/migrations/000039_add_addressee_to_letter_incoming.down.sql b/migrations/000039_add_addressee_to_letter_incoming.down.sql deleted file mode 100644 index fa9ca81..0000000 --- a/migrations/000039_add_addressee_to_letter_incoming.down.sql +++ /dev/null @@ -1,9 +0,0 @@ -BEGIN; - --- Drop index -DROP INDEX IF EXISTS idx_letters_incoming_addressee; - --- Remove addressee column from letters_incoming table -ALTER TABLE letters_incoming DROP COLUMN IF EXISTS addressee; - -COMMIT; \ No newline at end of file diff --git a/migrations/000039_add_addressee_to_letter_incoming.up.sql b/migrations/000039_add_addressee_to_letter_incoming.up.sql deleted file mode 100644 index 10b9d15..0000000 --- a/migrations/000039_add_addressee_to_letter_incoming.up.sql +++ /dev/null @@ -1,10 +0,0 @@ -BEGIN; - --- Add addressee column to letters_incoming table -ALTER TABLE letters_incoming -ADD COLUMN IF NOT EXISTS addressee TEXT; - --- Add index for addressee column for better search performance -CREATE INDEX IF NOT EXISTS idx_letters_incoming_addressee ON letters_incoming(addressee); - -COMMIT; \ No newline at end of file diff --git a/migrations/000040_add_archive_columns_to_letters_outgoing.down.sql b/migrations/000040_add_archive_columns_to_letters_outgoing.down.sql deleted file mode 100644 index f5cf477..0000000 --- a/migrations/000040_add_archive_columns_to_letters_outgoing.down.sql +++ /dev/null @@ -1,11 +0,0 @@ -BEGIN; - -DROP INDEX IF EXISTS idx_letters_outgoing_is_archived; -DROP INDEX IF EXISTS idx_letters_outgoing_archived_at; -DROP INDEX IF EXISTS idx_letters_outgoing_archived_status; - -ALTER TABLE letters_outgoing -DROP COLUMN IF EXISTS is_archived, -DROP COLUMN IF EXISTS archived_at; - -COMMIT; \ No newline at end of file diff --git a/migrations/000040_add_archive_columns_to_letters_outgoing.up.sql b/migrations/000040_add_archive_columns_to_letters_outgoing.up.sql deleted file mode 100644 index bf657b3..0000000 --- a/migrations/000040_add_archive_columns_to_letters_outgoing.up.sql +++ /dev/null @@ -1,12 +0,0 @@ -BEGIN; - -ALTER TABLE letters_outgoing -ADD COLUMN IF NOT EXISTS is_archived BOOLEAN NOT NULL DEFAULT false, -ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP WITHOUT TIME ZONE; - -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_is_archived ON letters_outgoing(is_archived); -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_archived_at ON letters_outgoing(archived_at); - -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_archived_status ON letters_outgoing(is_archived, status); - -COMMIT; \ No newline at end of file diff --git a/migrations/000041_add_archive_columns_to_letters_incoming.down.sql b/migrations/000041_add_archive_columns_to_letters_incoming.down.sql deleted file mode 100644 index efb58dc..0000000 --- a/migrations/000041_add_archive_columns_to_letters_incoming.down.sql +++ /dev/null @@ -1,11 +0,0 @@ -BEGIN; - -DROP INDEX IF EXISTS idx_letters_incoming_is_archived; -DROP INDEX IF EXISTS idx_letters_incoming_archived_at; -DROP INDEX IF EXISTS idx_letters_incoming_archived_status; - -ALTER TABLE letters_incoming -DROP COLUMN IF EXISTS is_archived, -DROP COLUMN IF EXISTS archived_at; - -COMMIT; \ No newline at end of file diff --git a/migrations/000041_add_archive_columns_to_letters_incoming.up.sql b/migrations/000041_add_archive_columns_to_letters_incoming.up.sql deleted file mode 100644 index 4aad3c3..0000000 --- a/migrations/000041_add_archive_columns_to_letters_incoming.up.sql +++ /dev/null @@ -1,12 +0,0 @@ -BEGIN; - -ALTER TABLE letters_incoming -ADD COLUMN IF NOT EXISTS is_archived BOOLEAN NOT NULL DEFAULT false, -ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP WITHOUT TIME ZONE; - -CREATE INDEX IF NOT EXISTS idx_letters_incoming_is_archived ON letters_incoming(is_archived); -CREATE INDEX IF NOT EXISTS idx_letters_incoming_archived_at ON letters_incoming(archived_at); - -CREATE INDEX IF NOT EXISTS idx_letters_incoming_archived_status ON letters_incoming(is_archived, status); - -COMMIT; \ No newline at end of file diff --git a/migrations/000042_add_revision_number_to_letter_outgoing.down.sql b/migrations/000042_add_revision_number_to_letter_outgoing.down.sql deleted file mode 100644 index 68cc7c0..0000000 --- a/migrations/000042_add_revision_number_to_letter_outgoing.down.sql +++ /dev/null @@ -1,8 +0,0 @@ -BEGIN; - -DROP INDEX IF EXISTS idx_letters_outgoing_revision_number; - -ALTER TABLE letters_outgoing -DROP COLUMN IF EXISTS revision_number; - -COMMIT; \ No newline at end of file diff --git a/migrations/000042_add_revision_number_to_letter_outgoing.up.sql b/migrations/000042_add_revision_number_to_letter_outgoing.up.sql deleted file mode 100644 index d820989..0000000 --- a/migrations/000042_add_revision_number_to_letter_outgoing.up.sql +++ /dev/null @@ -1,8 +0,0 @@ -BEGIN; - -ALTER TABLE letters_outgoing -ADD COLUMN IF NOT EXISTS revision_number INTEGER NOT NULL DEFAULT 0; - -CREATE INDEX IF NOT EXISTS idx_letters_outgoing_revision_number ON letters_outgoing(revision_number); - -COMMIT; \ No newline at end of file diff --git a/migrations/000043_add_revision_number_to_attachments_and_approvals.down.sql b/migrations/000043_add_revision_number_to_attachments_and_approvals.down.sql deleted file mode 100644 index 3e5bf2a..0000000 --- a/migrations/000043_add_revision_number_to_attachments_and_approvals.down.sql +++ /dev/null @@ -1,14 +0,0 @@ -BEGIN; - --- Drop indexes -DROP INDEX IF EXISTS idx_letter_outgoing_attachments_revision; -DROP INDEX IF EXISTS idx_letter_outgoing_approvals_revision; - --- Remove revision_number from tables -ALTER TABLE letter_outgoing_attachments -DROP COLUMN IF EXISTS revision_number; - -ALTER TABLE letter_outgoing_approvals -DROP COLUMN IF EXISTS revision_number; - -COMMIT; \ No newline at end of file diff --git a/migrations/000043_add_revision_number_to_attachments_and_approvals.up.sql b/migrations/000043_add_revision_number_to_attachments_and_approvals.up.sql deleted file mode 100644 index bef8a32..0000000 --- a/migrations/000043_add_revision_number_to_attachments_and_approvals.up.sql +++ /dev/null @@ -1,15 +0,0 @@ -BEGIN; - --- Add revision_number to letter_outgoing_attachments -ALTER TABLE letter_outgoing_attachments -ADD COLUMN IF NOT EXISTS revision_number INTEGER NOT NULL DEFAULT 0; - --- Add revision_number to letter_outgoing_approvals -ALTER TABLE letter_outgoing_approvals -ADD COLUMN IF NOT EXISTS revision_number INTEGER NOT NULL DEFAULT 0; - --- Create indexes for better query performance -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_attachments_revision ON letter_outgoing_attachments(letter_id, revision_number); -CREATE INDEX IF NOT EXISTS idx_letter_outgoing_approvals_revision ON letter_outgoing_approvals(letter_id, revision_number); - -COMMIT; \ No newline at end of file diff --git a/migrations/000044_create_repository_attachments_table.down.sql b/migrations/000044_create_repository_attachments_table.down.sql deleted file mode 100644 index e69de29..0000000 diff --git a/migrations/000044_create_repository_attachments_table.up.sql b/migrations/000044_create_repository_attachments_table.up.sql deleted file mode 100644 index 49e69f0..0000000 --- a/migrations/000044_create_repository_attachments_table.up.sql +++ /dev/null @@ -1,13 +0,0 @@ -BEGIN; - -CREATE TABLE IF NOT EXISTS repository_attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - file_url TEXT NOT NULL, - file_name TEXT NOT NULL, - file_type TEXT NOT NULL, - category TEXT NOT NULL, - uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL, - uploaded_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP - ); - -COMMIT; \ No newline at end of file diff --git a/migrations/000045_add_is_final_to_letter_outgoing_attachments.down.sql b/migrations/000045_add_is_final_to_letter_outgoing_attachments.down.sql deleted file mode 100644 index e69de29..0000000 diff --git a/migrations/000045_add_is_final_to_letter_outgoing_attachments.up.sql b/migrations/000045_add_is_final_to_letter_outgoing_attachments.up.sql deleted file mode 100644 index cd08359..0000000 --- a/migrations/000045_add_is_final_to_letter_outgoing_attachments.up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE letter_outgoing_attachments - ADD COLUMN IF NOT EXISTS is_final BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/migrations/000046_letter_outgoing_final_attachaments.down.sql b/migrations/000046_letter_outgoing_final_attachaments.down.sql deleted file mode 100644 index e69de29..0000000 diff --git a/migrations/000046_letter_outgoing_final_attachaments.up.sql b/migrations/000046_letter_outgoing_final_attachaments.up.sql deleted file mode 100644 index b89ad8f..0000000 --- a/migrations/000046_letter_outgoing_final_attachaments.up.sql +++ /dev/null @@ -1,12 +0,0 @@ -ALTER TABLE letter_outgoing_attachments - DROP COLUMN IF EXISTS is_final; - -CREATE TABLE IF NOT EXISTS letter_outgoing_final_attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - letter_id UUID NOT NULL REFERENCES letters_outgoing(id) ON DELETE CASCADE, - file_url TEXT NOT NULL, - file_name TEXT NOT NULL, - file_type TEXT NOT NULL, - uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL, - uploaded_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP -); \ No newline at end of file