Skip to content
Snippets Groups Projects
audit.go 18.1 KiB
Newer Older
	"github.com/r3labs/diff/v2"
	"github.com/samber/lo"
	"gitlab.bob.co.za/bob-public-utils/bobgroup-go-utils/errors"
	"gitlab.bob.co.za/bob-public-utils/bobgroup-go-utils/logs"
Francé Wilke's avatar
Francé Wilke committed
	"gitlab.bob.co.za/bob-public-utils/bobgroup-go-utils/number_utils"
	"gitlab.bob.co.za/bob-public-utils/bobgroup-go-utils/reflection"
	"gitlab.bob.co.za/bob-public-utils/bobgroup-go-utils/string_utils"
	"reflect"
	"regexp"
	"strconv"
	"strings"
Francé Wilke's avatar
Francé Wilke committed
type FieldChange struct {
	From any `json:"change_from"`
	To   any `json:"change_to"`
type IAuditFormatter interface {
	FormatForAuditEvent() string
}

func VerifyAuditEvents(original any, new any) error {
	if original != nil {
		structValue := reflect.ValueOf(original)
		if structValue.Kind() != reflect.Struct {
			return errors.Error("original object is not of type struct")
Johan de Klerk's avatar
Johan de Klerk committed
		structValue := reflect.ValueOf(new)
		if structValue.Kind() != reflect.Struct {
			return errors.Error("new object is not of type struct")
	return nil
}

func GetChanges(original any, new any) (map[string]any, error) {
	// Clean audit events
	original = cleanStruct(original)
	new = cleanStruct(new)
	changes := map[string]any{}
Francé Wilke's avatar
Francé Wilke committed
	changelog, err := diff.Diff(original, new)
Francé Wilke's avatar
Francé Wilke committed
		return changes, err
	}

	for _, change := range changelog {
Francé Wilke's avatar
Francé Wilke committed

		if len(change.Path) == 1 {
			// Root object change
			field := ToSnakeCase(change.Path[0])
			changes[field] = FieldChange{
				From: change.From,
				To:   change.To,
			}
		} else if len(change.Path) == 2 {
			// Child object changed
			// ["Account", "ID"]
			// 0 = Object
			// 1 = field
			var index int64 = -1

			if string_utils.IsNumericString(change.Path[1]) {
				index, _ = number_utils.StringToInt64(change.Path[1])
			}

			objectKey := ToSnakeCase(change.Path[0])
			didInsert := CheckToFormatForAuditEvent(changes, original, new, change.Path[0], objectKey, int(index))
			if didInsert {
				continue
			}
			ChildObjectChanges(changes, change.Path[0], change.Path[1], change.From, change.To)
		} else if len(change.Path) >= 3 {
Francé Wilke's avatar
Francé Wilke committed
			// Array of objects
			// ["Parcel", "0", "ActualWeight"]
			// 0 = Object
			// 1 = Index of object
			// 2 = field

			objectKey := ToSnakeCase(change.Path[0])
			indexString := change.Path[1]

			if !string_utils.IsNumericString(indexString) {
				// Not an array, but a deeper nested object
				didInsert := CheckToFormatForAuditEvent(changes, original, new, change.Path[0], objectKey, -1)
				if didInsert {
					continue
				}

				ChildObjectChanges(changes, change.Path[len(change.Path)-3], change.Path[len(change.Path)-1], change.From, change.To)
Francé Wilke's avatar
Francé Wilke committed
			index, _ := number_utils.StringToInt64(indexString)
			field := ToSnakeCase(change.Path[2])
Francé Wilke's avatar
Francé Wilke committed

			didInsert := CheckToFormatForAuditEvent(changes, original, new, change.Path[0], objectKey, int(index))
			if didInsert {
				continue
			}

			if len(change.Path) == 5 && string_utils.IsNumericString(change.Path[3]) {
				// The field is actually an array of objects.
				field += fmt.Sprintf("[%s] (%s)", change.Path[3], ToSnakeCase(change.Path[4]))
			}

			arrayObject, present := changes[objectKey]
			if present {
				if arrayOfObjects, ok := arrayObject.([]map[string]any); ok {
					arrayIndex := ArrayIndexForObjectIndex(arrayOfObjects, index)
					if arrayIndex != -1 {
						// Add field to existing object in array
						object[field] = FieldChange{
							From: change.From,
							To:   change.To,
						}
					} else {
						// new object, append to existing array
						fieldChange := map[string]any{
							field: FieldChange{
								From: change.From,
								To:   change.To,
							},
						}
						changes[objectKey] = append(arrayOfObjects, fieldChange)
					}

				}
			} else {
				// Create array of objects
				fieldChange := map[string]any{
					field: FieldChange{
						From: change.From,
						To:   change.To,
					},
				}
				changes[objectKey] = []map[string]any{
func cleanStruct(object any) any {
	defer func() {
		if err := recover(); err != nil {
			logs.ErrorMsg(fmt.Sprintf("audit event panic: %+v", err))
		}
	}()

	// If the object is empty, we have nothing to do
	if object == nil {
		return object
	}

	// Convert the object to a pointer
	if reflect.ValueOf(object).Kind() != reflect.Ptr {
		val := reflect.ValueOf(object)
		// Create a new pointer to a new value of the type of the object
		ptr := reflect.New(reflect.TypeOf(object))
		// Set the newly created pointer to point to the object
		ptr.Elem().Set(val)
		// Overwrite the original object
		object = ptr.Interface()
	}

	// Get the value of the object
	val := reflect.ValueOf(object)
	if val.Kind() == reflect.Ptr {
		val = val.Elem()
	}

	// We can only clean structs
	if val.Kind() != reflect.Struct {
		return object
	}

	// Loop through the field tags to see if we should include the related object or not.
	// We default to exclude, unless specified to include
	for i := 0; i < val.NumField(); i++ {
		fieldVal := val.Field(i)
		structField := val.Type().Field(i)

		// Determine whether the field should be included or excluded
		auditTag := structField.Tag.Get("audit")
		auditTagOptions := strings.Split(auditTag, ",")
		shouldIncludeForAudit := lo.Contains(auditTagOptions, "true")
		shouldExcludeForAudit := lo.Contains(auditTagOptions, "false")

		// If the audit tag is present and specified to 'true', we should always include the relation
		if shouldIncludeForAudit {
			continue
		}

		// By default, all bun relations are excluded
		isBunRelationField := strings.Contains(structField.Tag.Get("bun"), "rel:")
		if shouldExcludeForAudit || isBunRelationField {
			if fieldVal.CanSet() {
				// Set the field to its zero value (nil for pointers)
				fieldVal.Set(reflect.Zero(fieldVal.Type()))
			}
		}
	}

	return object
}

func CheckToFormatForAuditEvent(changes map[string]any, original any, new any, field string, objectKey string, index int) (didInsert bool) {
	originalStructField, originalFieldValue, found := getFieldFromStruct(original, field)
	if !found {
		return false
	}
	_, newFieldValue, found := getFieldFromStruct(new, field)
	if !found {
		return false
	}

	defer func() {
		if err := recover(); err != nil {
			logs.ErrorWithFields(map[string]any{
				"error":    err,
				"field":    field,
				"index":    index,
				"original": fmt.Sprintf("%#v", originalFieldValue),
				"new":      fmt.Sprintf("%#v", newFieldValue),
			}, errors.Error("Failed to format for audit event"))
			didInsert = false
	doGroupSlice := doGroupSliceForAuditEvent(originalStructField)

	if !doGroupSlice {
		originalFormattedObject := checkToFormatForAuditEvent(originalFieldValue, index)
		newFormattedObject := checkToFormatForAuditEvent(newFieldValue, index)
		if originalFormattedObject != nil ||
			newFormattedObject != nil {
			if index > -1 {
				objectKey = fmt.Sprintf("%s[%d]", field, index)
			}

			changes[objectKey] = FieldChange{
				From: originalFormattedObject,
				To:   newFormattedObject,
			}
	} else {
		originalFormattedObject := checkToFormatForGroupedAuditEvent(originalFieldValue)
		newFormattedObject := checkToFormatForGroupedAuditEvent(newFieldValue)
		if originalFormattedObject != nil ||
			newFormattedObject != nil {
			if _, present := changes[objectKey]; !present {
				fieldChange := FieldChange{}
				if originalFormattedObject != nil {
					fieldChange.From = originalFormattedObject
				} else {
					fieldChange.From = []*string{}
				}
				if newFormattedObject != nil {
					fieldChange.To = newFormattedObject
				} else {
					fieldChange.To = []*string{}
				}
				changes[objectKey] = fieldChange
			}
	}

	return false
}

func appendNewChangeToExistingChanges(existingChanges any, newChange string) any {
	existingChangesStrings, ok := existingChanges.([]string)
	if !ok {
		return existingChanges
	}

	if !lo.Contains(existingChangesStrings, newChange) {
		existingChangesStrings = append(existingChangesStrings, newChange)
	}

	return existingChangesStrings
}

func getFieldFromStruct(object any, field string) (reflect.StructField, reflect.Value, bool) {
	objectValue := reflect.ValueOf(object)
	if objectValue.Kind() == reflect.Ptr {
		objectValue = objectValue.Elem()
	}

	if objectValue.Kind() != reflect.Struct {
		// Fields can only be retrieved from structs
		return reflect.StructField{}, reflect.Value{}, false
	}

	objectStructField, found := objectValue.Type().FieldByName(field)
	if !found {
		return reflect.StructField{}, reflect.Value{}, false
	}

	objectFieldValue := objectValue.FieldByName(field)

	return objectStructField, objectFieldValue, true
}

func doGroupSliceForAuditEvent(objectField reflect.StructField) bool {
	auditTag, found := objectField.Tag.Lookup("audit")
	if found {
		auditTagOptions := strings.Split(auditTag, ",")
		if lo.Contains(auditTagOptions, "group") {
			return true
		}
	}

	return false
}

func checkToExecuteAuditFormatter(fieldValue reflect.Value) *string {
	if auditFormatterObject, ok := fieldValue.Interface().(IAuditFormatter); ok {
		fieldString := auditFormatterObject.FormatForAuditEvent()
		if fieldString != "" {
			return &fieldString
		}
	}
	return nil
}

func checkToFormatForGroupedAuditEvent(fieldValue reflect.Value) []*string {
	if fieldValue.Kind() == reflect.Ptr {
		if fieldValue.IsNil() {
			return nil
		}
		fieldValue = fieldValue.Elem()
	}

	var formattedField []*string
	if fieldValue.Kind() == reflect.Struct {
		formattedField = []*string{checkToExecuteAuditFormatter(fieldValue)}
	} else if fieldValue.Kind() == reflect.Slice {
		for i := 0; i < fieldValue.Len(); i++ {
			sliceFieldValue := fieldValue.Index(i)
			if sliceFieldValue.Kind() == reflect.Ptr {
				if sliceFieldValue.IsNil() {
					continue
				}
				sliceFieldValue = sliceFieldValue.Elem()
			}

			if sliceFieldValue.Kind() != reflect.Struct {
				// Not a slice of structs - ignore the format flag
				return nil
			}

			formattedField = append(formattedField, checkToExecuteAuditFormatter(sliceFieldValue))
		}

	}

	return formattedField
}

func checkToFormatForAuditEvent(fieldValue reflect.Value, index int) *string {
	if fieldValue.Kind() == reflect.Ptr {
		fieldValue = fieldValue.Elem()
	}

	var formattedField *string
	if fieldValue.Kind() == reflect.Struct {
		formattedField = checkToExecuteAuditFormatter(fieldValue)
	} else if fieldValue.Kind() == reflect.Slice &&
		fieldValue.Len() > 0 {
			return nil
		}

		sliceFieldValue := fieldValue.Index(index)
		if sliceFieldValue.Kind() == reflect.Ptr {
			sliceFieldValue = sliceFieldValue.Elem()
		}

		if sliceFieldValue.Kind() != reflect.Struct {
			// Not a slice of structs - ignore the format flag
			return nil
		}

		formattedField = checkToExecuteAuditFormatter(sliceFieldValue)
	}

	return formattedField
}

func ChildObjectChanges(changes map[string]any, objectPath string, fieldPath string, changeFrom any, changeTo any) {

	objectKey := ToSnakeCase(objectPath)
	field := ToSnakeCase(fieldPath)

	existingObject, present := changes[objectKey]
	if present {
		if object, ok := existingObject.(map[string]any); ok {
			object[field] = FieldChange{
				From: changeFrom,
				To:   changeTo,
			}
			changes[objectKey] = object
		}
	} else {
		fieldChange := map[string]any{
			field: FieldChange{
				From: changeFrom,
				To:   changeTo,
			},
		}
		changes[objectKey] = fieldChange
	}
}
// ArrayIndexForObjectIndex gets the index of arrayOfObjects where the object's index field is equal to objectIndex.
func ArrayIndexForObjectIndex(arrayOfObjects []map[string]any, objectIndex int64) int {
	for arrayIndex, object := range arrayOfObjects {
		index, present := object["index"]
		if present {
			if index == objectIndex {
				return arrayIndex
			}
		}
	}

	return -1
}

// GetAllChanges Returns the diff, structured in json, recursively
// Be warned, here be dragons. Debug this first to understand how it works
func GetAllChanges(original any, new any) (map[string]any, error) {
	changes := map[string]any{}
	changelog, err := diff.Diff(original, new)
	if err != nil {
		return changes, err
	}

	changesJson := "{"
	subArrays := map[string]string{}
	for _, change := range changelog {
		var value string   // Keep track of the core value
		var key string     // Keep track of the key/s for this value
		var closing string // Keep track of the correct number of closing characters

		for pathIndex := len(change.Path) - 1; pathIndex >= 0; pathIndex-- {
			// If this is the first value, it's the "deepest" value of this change set, otherwise leave this var alone
			// If this is set at the wrong point, other behaviour changes
			if pathIndex+1 == len(change.Path) {
				changedField := FieldChange{
					From: change.From,
					To:   change.To,
				}
				request, err := json.Marshal(changedField)
				if err != nil {
					return nil, err
				}
				value = string(request)
				value = value[1 : len(value)-1]
			}

			// If this "key" is integer-like, we handle it a little differently to ensure related data ends up
			// together and formatted as you would expect JSON arrays to look
			if _, convErr := strconv.Atoi(change.Path[pathIndex]); convErr == nil {
				positionOfSubArray := ""
				for sub := 0; sub <= pathIndex; sub++ {
					positionOfSubArray = positionOfSubArray + change.Path[sub]
				}

				// Add it to the placeholder data map used later on
				subArrays[positionOfSubArray] = subArrays[positionOfSubArray] + key + value + closing + ","

				// Don't insert the same placeholder into the json multiple times
				if !strings.Contains(changesJson, positionOfSubArray) {
					value = positionOfSubArray
				} else {
					// Make sure this value doesn't end up populated explicitly
					value = ""
				}
			} else {
				// Safety net so we don't compare to a non existant value
				if pathIndex < len(change.Path)-1 {
					// If the value this one "contains" has a integer-like key, this is probably an array
					if _, err = strconv.Atoi(change.Path[pathIndex+1]); err == nil {
						key = "\"" + ToSnakeCase(change.Path[pathIndex]) + "\": ["
						closing = "]"
					} else {
						key = "\"" + ToSnakeCase(change.Path[pathIndex]) + "\": {" + key
						closing = closing + "}"
					}
				} else {
					key = "\"" + ToSnakeCase(change.Path[pathIndex]) + "\": {" + key
					closing = closing + "}"
				}
			}
		}

		// Don't insert empty values (happens when multiple values within an array are set, see "placeholder" behavior)
		if value != "" {
			// Duplicate key prevention
			keySplit := strings.Split(key, ": ")
			lastOccurrence := 0
			lastMatched := ""
			levelsDeep := 0
			for sIndex, split := range keySplit {
				// Trim leading character off split
				if split[0:1] == "{" || split[0:1] == "[" {
					split = split[1:]
				}

				// The final value of keySplit might be empty ("")
				if split != "" {
					if index := strings.Index(changesJson, split); index > -1 && changesJson != "{" {
						// Prevent reverse traversal
						if index > lastOccurrence {
							// Prevent finding nested keys as opposed to our "change" keys
							opener := changesJson[index+len(split)+2 : index+len(split)+3]
							// Prevent a "base level" key from matching a nested key
							baseLevel := false
Ruaan Burger's avatar
Ruaan Burger committed
							if index > 1 {
								containedBy := changesJson[index-1 : index]
								if levelsDeep == 0 && (containedBy == "{" || containedBy == "[") {
									baseLevel = true
								}
							}
							if (opener == "{" || opener == "[") && !baseLevel {
								lastOccurrence = index
								lastMatched = split
								levelsDeep = sIndex
							}
						}
					}
				}
			}

			// If the "key" is already present, handle it differently
			if lastOccurrence > 0 {
				// Strip parent keys that are already present
				key = key[strings.Index(key, lastMatched)+len(lastMatched):]
				if key[0:2] == ": " {
					key = key[2:]
				}
				// Strip the correct amount of closing tags
				closing = closing[:len(closing)-levelsDeep]

				// Find the position of this key in the master string
				position := strings.Index(changesJson, lastMatched) + len(lastMatched) + 3

				// We're appending to an existing object, so strip the outermost wrapping layer
				key = key[1:]
				closing = closing[:len(closing)-1]

				// Place the value within the existing key
				changesJson = changesJson[:position] + key + value + closing + ", " + changesJson[position:]
			} else {
				// No value found, append it to the end
				changesJson = changesJson + key + value + closing + ", "
			}
		}
	}
	// Trim whitespace and strip trailing comma since we are done inserting at the back
	changesJson = strings.TrimSpace(changesJson)
	if changesJson[len(changesJson)-1:] == "," {
		changesJson = changesJson[:len(changesJson)-1]
	}
	changesJson = changesJson + "}"

	// Now we can go make sure the placeholders are populated with the data in
	for placeholderKey, placeholderValue := range subArrays {
		// Trim the trailing comma since we won't be adding any more stuff
		placeholderValue = strings.TrimSpace(placeholderValue)
		if placeholderValue[len(placeholderValue)-1:] == "," {
			placeholderValue = placeholderValue[:len(placeholderValue)-1]
		changesJson = strings.ReplaceAll(changesJson, placeholderKey, "{"+placeholderValue+"}")
	}

	// Now, hopefully, the json parsing will pass and we have a nicely formatted dataset
	err = json.Unmarshal([]byte(changesJson), &changes)
	if err != nil {
		return nil, err
Francé Wilke's avatar
Francé Wilke committed
	return changes, nil
}

var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")

func ToSnakeCase(str string) string {
	snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
	snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
	return strings.ToLower(snake)
}

func GetIntValue(object any, key string) int64 {
	structValue := reflect.ValueOf(object)
	if structValue.Kind() == reflect.Struct {
		field := structValue.FieldByName(key)
		id := reflection.GetInt64Value(field)
		return id
	}
	return 0
}

func GetStringValue(object any, key string) string {
	structValue := reflect.ValueOf(object)
	if structValue.Kind() == reflect.Struct {
		field := structValue.FieldByName(key)
		id := reflection.GetStringValue(field)
		return id
	}
	return ""
}