add dukcapil

This commit is contained in:
Aditya Siregar
2026-05-07 04:01:32 +07:00
parent 9a975d146e
commit bc64eb20ea
235 changed files with 1197 additions and 28187 deletions
-415
View File
@@ -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
}
}
-263
View File
@@ -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
}
+56 -120
View File
@@ -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)
}
@@ -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
}
+70
View File
@@ -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":{"<NIK>":<score>, ...}}}` 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)
-109
View File
@@ -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 ""
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-633
View File
@@ -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
}
-385
View File
@@ -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
}
-708
View File
@@ -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
}
-286
View File
@@ -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
}
@@ -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)
}
@@ -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
}
-34
View File
@@ -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)
}
+53 -83
View File
@@ -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)
}