package audit import ( "context" "encoding/json" "fmt" ) // undoableActions is the set of actions that can be undone. // Clock actions, tournament lifecycle, and operator actions are not undoable. var undoableActions = map[string]bool{ ActionPlayerBuyin: true, ActionPlayerBust: true, ActionPlayerRebuy: true, ActionPlayerAddon: true, ActionPlayerReentry: true, ActionFinancialBuyin: true, ActionFinancialRebuy: true, ActionFinancialAddon: true, ActionFinancialPayout: true, ActionFinancialChop: true, ActionFinancialBubblePrize: true, ActionSeatMove: true, ActionSeatBalance: true, } // Errors returned by the undo engine. var ( ErrAlreadyUndone = fmt.Errorf("audit: entry has already been undone") ErrNotUndoable = fmt.Errorf("audit: action type is not undoable") ErrEntryNotFound = fmt.Errorf("audit: entry not found") ) // UndoEngine reverses audited actions by creating new audit entries that // reverse the original. It never deletes or modifies existing entries // (except marking the undone_by field on the original). type UndoEngine struct { trail *Trail } // NewUndoEngine creates a new undo engine backed by the given audit trail. func NewUndoEngine(trail *Trail) *UndoEngine { return &UndoEngine{trail: trail} } // CanUndo checks if the given audit entry can be undone. // Returns true if the entry exists, has an undoable action type, and hasn't been undone yet. func (u *UndoEngine) CanUndo(ctx context.Context, auditEntryID string) (bool, error) { entry, err := u.trail.GetEntry(ctx, auditEntryID) if err != nil { return false, err } // Already undone if entry.UndoneBy != nil { return false, nil } // Check if action type is undoable return undoableActions[entry.Action], nil } // Undo creates a reversal audit entry for the given entry. // It: // 1. Loads the original entry // 2. Verifies it hasn't been undone and is undoable // 3. Creates a NEW audit entry with reversed state // 4. Marks the original entry's undone_by field // 5. Returns the undo entry for the caller to perform the actual state reversal func (u *UndoEngine) Undo(ctx context.Context, auditEntryID string) (*AuditEntry, error) { // Load the original entry original, err := u.trail.GetEntry(ctx, auditEntryID) if err != nil { return nil, err } // Verify it hasn't already been undone if original.UndoneBy != nil { return nil, ErrAlreadyUndone } // Verify the action type is undoable if !undoableActions[original.Action] { return nil, ErrNotUndoable } // Create metadata for the undo entry undoMetadata, err := json.Marshal(map[string]interface{}{ "undone_entry_id": original.ID, }) if err != nil { return nil, fmt.Errorf("audit: marshal undo metadata: %w", err) } // Create the undo entry with reversed state undoEntry := AuditEntry{ TournamentID: original.TournamentID, Action: "undo." + original.Action, TargetType: original.TargetType, TargetID: original.TargetID, PreviousState: original.NewState, // Reversed: new becomes previous NewState: original.PreviousState, // Reversed: previous becomes new Metadata: undoMetadata, } // Record the undo entry recorded, err := u.trail.Record(ctx, undoEntry) if err != nil { return nil, fmt.Errorf("audit: record undo entry: %w", err) } // Mark the original entry as undone // This is the ONE exception to append-only: marking undone_by on an entry where it's NULL. // The tamper-protection trigger in the schema allows this specific update. _, err = u.trail.db.ExecContext(ctx, "UPDATE audit_entries SET undone_by = ? WHERE id = ? AND undone_by IS NULL", recorded.ID, original.ID, ) if err != nil { return nil, fmt.Errorf("audit: mark entry as undone: %w", err) } return recorded, nil }