All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
501 lines
13 KiB
Go
501 lines
13 KiB
Go
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
|
|
}
|