cargo-platform/internal/usecases/activity_log.go
teguh nugroho 39265bfcb1
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
oerubahan
2026-09-05 14:31:03 +07:00

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
}