- SUMMARY.md with self-check passed - STATE.md updated: plan 6 of 14, 36% progress, 4 decisions added - ROADMAP.md updated: 5/14 plans complete - REQUIREMENTS.md: 15 requirements marked complete (BLIND-01-06, CHIP-01-04, FIN-01,02,05,06,10) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
126 lines
3.7 KiB
Go
126 lines
3.7 KiB
Go
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
|
|
}
|