package usecases import ( "cargo-erp-backend/internal/domain" "cargo-erp-backend/internal/handlers/dto" "cargo-erp-backend/pkg/datatable" "cargo-erp-backend/pkg/helpers" "encoding/json" "fmt" "strings" "sync" "time" "go.uber.org/zap" "gorm.io/gorm" ) type CascadeRule struct { Table string FKColumn string DeleteRule string } type CascadeGroup struct { Table string `json:"table"` FKColumn string `json:"fk_column"` DeleteRule string `json:"delete_rule"` Records []map[string]interface{} `json:"records"` } var ( CascadeMap map[string][]CascadeRule CascadeMapMu sync.RWMutex ) type ActivityLogUsecaseInterface interface { GetAll(filter dto.ActivityLogFilterRequest) ([]dto.ActivityLogResponse, int64, error) GetList(req dto.DataTableRequest) (dto.DataTableResponse, error) GetByID(id string) (dto.ActivityLogResponse, error) Revert(logID string) error LogCreate(tableName string, record interface{}, merchantID *string) LogUpdate(tableName string, oldData, newData interface{}, merchantID *string) LogDelete(tableName string, recordID string, oldData interface{}, merchantID *string) } type ActivityLogUsecase struct { Helper helpers.HelperInterface AuthUser domain.User } func NewActivityLogUsecase(helper helpers.HelperInterface, authUser domain.User) ActivityLogUsecaseInterface { return &ActivityLogUsecase{Helper: helper, AuthUser: authUser} } func LoadCascadeRules(db *gorm.DB) error { rows, err := db.Raw(` SELECT tc.table_name AS source_table, kcu.column_name AS source_column, ccu.table_name AS target_table, ccu.column_name AS target_column, rc.delete_rule FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = ccu.table_schema JOIN information_schema.referential_constraints rc ON rc.constraint_name = tc.constraint_name AND rc.constraint_schema = tc.table_schema WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public' ORDER BY tc.table_name `).Rows() if err != nil { return fmt.Errorf("failed to query FK constraints: %w", err) } defer rows.Close() newMap := make(map[string][]CascadeRule) for rows.Next() { var fk ForeignKeyInfo if err := rows.Scan(&fk.SourceTable, &fk.SourceColumn, &fk.TargetTable, &fk.TargetColumn, &fk.DeleteRule); err != nil { continue } if _, ok := newMap[fk.TargetTable]; !ok { newMap[fk.TargetTable] = make([]CascadeRule, 0) } newMap[fk.TargetTable] = append(newMap[fk.TargetTable], CascadeRule{ Table: fk.SourceTable, FKColumn: fk.SourceColumn, DeleteRule: fk.DeleteRule, }) } CascadeMapMu.Lock() CascadeMap = newMap CascadeMapMu.Unlock() return nil } type ForeignKeyInfo struct { SourceTable string SourceColumn string TargetTable string TargetColumn string DeleteRule string } func CaptureCascade(tx *gorm.DB, tableName string, recordID string) ([]CascadeGroup, error) { groups := make([]CascadeGroup, 0) visited := make(map[string]bool) CascadeMapMu.RLock() defer CascadeMapMu.RUnlock() var walk func(table string, ids []string) walk = func(table string, ids []string) { if len(ids) == 0 { return } rules, ok := CascadeMap[table] if !ok { return } for _, rule := range rules { key := rule.Table + ":" + strings.Join(ids, ",") if visited[key] { continue } visited[key] = true var records []map[string]interface{} if err := tx.Table(rule.Table).Where(rule.FKColumn+" IN ?", ids).Find(&records).Error; err != nil { continue } if len(records) > 0 { groups = append(groups, CascadeGroup{ Table: rule.Table, FKColumn: rule.FKColumn, DeleteRule: rule.DeleteRule, Records: records, }) childIDs := extractIDs(records, "id") walk(rule.Table, childIDs) } } } walk(tableName, []string{recordID}) return groups, nil } func extractIDs(records []map[string]interface{}, key string) []string { ids := make([]string, 0, len(records)) for _, r := range records { if v, ok := r[key]; ok && v != nil { switch id := v.(type) { case string: ids = append(ids, id) case float64: ids = append(ids, fmt.Sprintf("%.0f", id)) } } } return ids } func toJSON(v interface{}) *string { if v == nil { return nil } b, err := json.Marshal(v) if err != nil { return nil } s := string(b) return &s } func (u *ActivityLogUsecase) LogCreate(tableName string, record interface{}, merchantID *string) { db := u.Helper.GetDB("master") log := domain.ActivityLog{ TargetTable: tableName, RecordID: extractRecordID(record), Action: "CREATE", NewData: toJSON(record), UserID: u.AuthUser.ID, UserName: u.getDisplayName(), MerchantID: merchantID, CreatedOn: timePtr(time.Now()), } db.Create(&log) } func (u *ActivityLogUsecase) LogUpdate(tableName string, oldData, newData interface{}, merchantID *string) { db := u.Helper.GetDB("master") log := domain.ActivityLog{ TargetTable: tableName, RecordID: extractRecordID(oldData), Action: "UPDATE", OldData: toJSON(oldData), NewData: toJSON(newData), UserID: u.AuthUser.ID, UserName: u.getDisplayName(), MerchantID: merchantID, CreatedOn: timePtr(time.Now()), } db.Create(&log) } func (u *ActivityLogUsecase) LogDelete(tableName string, recordID string, oldData interface{}, merchantID *string) { db := u.Helper.GetDB("master") var cascadeData []CascadeGroup err := db.Transaction(func(tx *gorm.DB) error { var err error cascadeData, err = CaptureCascade(tx, tableName, recordID) return err }) if err != nil { u.Helper.Log().Error("failed to capture cascade", zap.Error(err)) cascadeData = []CascadeGroup{} } log := domain.ActivityLog{ TargetTable: tableName, RecordID: recordID, Action: "DELETE", OldData: toJSON(oldData), CascadeData: toJSON(cascadeData), UserID: u.AuthUser.ID, UserName: u.getDisplayName(), MerchantID: merchantID, CreatedOn: timePtr(time.Now()), } db.Create(&log) } func (u *ActivityLogUsecase) GetAll(filter dto.ActivityLogFilterRequest) ([]dto.ActivityLogResponse, int64, error) { var logs []domain.ActivityLog var total int64 db := u.Helper.GetDB("slave") tx := db.Model(&logs) if filter.TableName != "" { tx = tx.Where("table_name = ?", filter.TableName) } if filter.RecordID != "" { tx = tx.Where("record_id = ?", filter.RecordID) } if filter.Action != "" { tx = tx.Where("action = ?", filter.Action) } if filter.UserID != "" { tx = tx.Where("user_id = ?", filter.UserID) } if filter.MerchantID != "" { tx = tx.Where("merchant_id = ?", filter.MerchantID) } if filter.DateFrom != "" { tx = tx.Where("created_on >= ?", filter.DateFrom) } if filter.DateTo != "" { tx = tx.Where("created_on <= ?", filter.DateTo) } tx.Count(&total) tx.Order("created_on DESC") if err := tx.Find(&logs).Error; err != nil { return nil, 0, err } responses := make([]dto.ActivityLogResponse, 0, len(logs)) for _, l := range logs { responses = append(responses, toActivityLogResponse(l)) } return responses, total, nil } func (u *ActivityLogUsecase) GetList(req dto.DataTableRequest) (dto.DataTableResponse, error) { db := u.Helper.GetDB("slave") tx := db.Table("activity_log") dt := datatable.NewDatatable(tx, req) coldef := make([]dto.DataTableColDef, 0) coldef = append(coldef, dto.DataTableColDef{Field: "activity_log.table_name", Alias: "table_name"}) coldef = append(coldef, dto.DataTableColDef{Field: "activity_log.record_id", Alias: "record_id"}) coldef = append(coldef, dto.DataTableColDef{Field: "activity_log.action", Alias: "action"}) coldef = append(coldef, dto.DataTableColDef{Field: "activity_log.user_id", Alias: "user_id"}) coldef = append(coldef, dto.DataTableColDef{Field: "activity_log.user_name", Alias: "user_name"}) coldef = append(coldef, dto.DataTableColDef{Field: "activity_log.id", Alias: "id"}) return dt.Render(coldef), nil } func (u *ActivityLogUsecase) GetByID(id string) (dto.ActivityLogResponse, error) { var log domain.ActivityLog db := u.Helper.GetDB("slave") if err := db.Where("id = ?", id).First(&log).Error; err != nil { return dto.ActivityLogResponse{}, err } return toActivityLogResponse(log), nil } func (u *ActivityLogUsecase) Revert(logID string) error { db := u.Helper.GetDB("master") var log domain.ActivityLog if err := db.Where("id = ?", logID).First(&log).Error; err != nil { return fmt.Errorf("activity log not found") } switch log.Action { case "CREATE": return u.revertCreate(db, log) case "UPDATE": return u.revertUpdate(db, log) case "DELETE": return u.revertDelete(db, log) default: return fmt.Errorf("unsupported action: %s", log.Action) } } func (u *ActivityLogUsecase) revertCreate(db *gorm.DB, log domain.ActivityLog) error { return db.Transaction(func(tx *gorm.DB) error { if err := tx.Exec("DELETE FROM "+log.TargetTable+" WHERE id = ?", log.RecordID).Error; err != nil { return err } u.logRevert("CREATE", log) return nil }) } func (u *ActivityLogUsecase) revertUpdate(db *gorm.DB, log domain.ActivityLog) error { return db.Transaction(func(tx *gorm.DB) error { if log.OldData == nil { return fmt.Errorf("no old data to revert") } var oldRecord map[string]interface{} if err := json.Unmarshal([]byte(*log.OldData), &oldRecord); err != nil { return fmt.Errorf("failed to parse old data: %w", err) } cleaned := cleanRecordMap(oldRecord) delete(cleaned, "created_on") delete(cleaned, "created_by") if err := tx.Table(log.TargetTable).Where("id = ?", log.RecordID).Updates(cleaned).Error; err != nil { return err } u.logRevert("UPDATE", log) return nil }) } func (u *ActivityLogUsecase) revertDelete(db *gorm.DB, log domain.ActivityLog) error { return db.Transaction(func(tx *gorm.DB) error { var cascadeGroups []CascadeGroup if log.CascadeData != nil { if err := json.Unmarshal([]byte(*log.CascadeData), &cascadeGroups); err != nil { return fmt.Errorf("failed to parse cascade data: %w", err) } } for i := len(cascadeGroups) - 1; i >= 0; i-- { group := cascadeGroups[i] for _, record := range group.Records { cleaned := cleanRecordMap(record) tx.Table(group.Table).Create(cleaned) } } if log.OldData != nil { var mainRecord map[string]interface{} if err := json.Unmarshal([]byte(*log.OldData), &mainRecord); err == nil { cleaned := cleanRecordMap(mainRecord) tx.Table(log.TargetTable).Create(cleaned) } } u.logRevert("DELETE", log) return nil }) } func (u *ActivityLogUsecase) logRevert(originalAction string, originalLog domain.ActivityLog) { db := u.Helper.GetDB("master") revertLog := domain.ActivityLog{ TargetTable: originalLog.TargetTable, RecordID: originalLog.RecordID, Action: "REVERT", OldData: toJSON(fmt.Sprintf("Reverted %s action (log_id: %s)", originalAction, originalLog.ID)), UserID: u.AuthUser.ID, UserName: u.getDisplayName(), MerchantID: originalLog.MerchantID, CreatedOn: timePtr(time.Now()), } db.Create(&revertLog) } func (u *ActivityLogUsecase) getDisplayName() string { if u.AuthUser.NickName != nil && *u.AuthUser.NickName != "" { return *u.AuthUser.NickName } if u.AuthUser.Email != nil { return *u.AuthUser.Email } return u.AuthUser.ID } func extractRecordID(record interface{}) string { switch v := record.(type) { case map[string]interface{}: if id, ok := v["id"].(string); ok { return id } case domain.Province: return v.ID case domain.City: return v.ID case domain.District: return v.ID case domain.Subdistrict: return v.ID case domain.PostalCode: return v.ID case domain.CostComponent: return v.ID case domain.PackageType: return v.ID case domain.Moda: return v.ID case domain.BasicPricing: return v.ID } b, err := json.Marshal(record) if err != nil { return "" } var m map[string]interface{} if err := json.Unmarshal(b, &m); err != nil { return "" } if id, ok := m["id"].(string); ok { return id } return "" } func timePtr(t time.Time) *time.Time { return &t } func toActivityLogResponse(log domain.ActivityLog) dto.ActivityLogResponse { resp := dto.ActivityLogResponse{ ID: log.ID, TableName: log.TargetTable, RecordID: log.RecordID, Action: log.Action, OldData: log.OldData, NewData: log.NewData, CascadeData: log.CascadeData, UserID: log.UserID, UserName: log.UserName, MerchantID: log.MerchantID, } if log.CreatedOn != nil { s := log.CreatedOn.Format("2006-01-02 15:04:05") resp.CreatedOn = &s } if log.CascadeData != nil { var groups []CascadeGroup if err := json.Unmarshal([]byte(*log.CascadeData), &groups); err == nil { count := 0 for _, g := range groups { count += len(g.Records) } resp.CascadeCount = count } } return resp } func cleanRecordMap(record map[string]interface{}) map[string]interface{} { cleaned := make(map[string]interface{}) for k, v := range record { if v == nil { continue } switch v.(type) { case map[string]interface{}, []interface{}: continue } cleaned[k] = v } return cleaned }