package storage import ( "context" "encoding/json" "fmt" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/denisovdennis/autohero/internal/model" ) // LogEntry represents a single adventure log row for API/JSON. type LogEntry struct { ID int64 `json:"id"` HeroID int64 `json:"heroId"` Message string `json:"message"` CreatedAt time.Time `json:"createdAt"` Event *model.AdventureLogEvent `json:"event,omitempty"` } // LogStore handles adventure log CRUD operations against PostgreSQL. type LogStore struct { pool *pgxpool.Pool } // NewLogStore creates a new LogStore backed by the given connection pool. func NewLogStore(pool *pgxpool.Pool) *LogStore { return &LogStore{pool: pool} } // Add inserts a new adventure log entry for the given hero. func (s *LogStore) Add(ctx context.Context, heroID int64, line model.AdventureLogLine) error { var code *string var argsJSON []byte var err error if line.Event != nil { c := line.Event.Code code = &c if line.Event.Args != nil { argsJSON, err = json.Marshal(line.Event.Args) if err != nil { return fmt.Errorf("marshal event args: %w", err) } } } msg := strings.TrimSpace(line.Message) if msg == "" && line.Event != nil { msg = model.EnglishAdventureLogFallback(line.Event) } _, err = s.pool.Exec(ctx, `INSERT INTO adventure_log (hero_id, message, event_code, event_args) VALUES ($1, $2, $3, $4)`, heroID, msg, code, argsJSON, ) if err != nil { return fmt.Errorf("add log entry: %w", err) } return nil } func logEntryFromScan(id int64, heroID int64, message string, createdAt time.Time, code *string, argsBytes []byte) LogEntry { e := LogEntry{ID: id, HeroID: heroID, Message: message, CreatedAt: createdAt} if code != nil && *code != "" { ev := model.AdventureLogEvent{Code: *code} if len(argsBytes) > 0 { if err := json.Unmarshal(argsBytes, &ev.Args); err != nil { ev.Args = map[string]any{"_raw": string(argsBytes)} } } e.Event = &ev } return e } // GetSince returns log entries for a hero created after the given timestamp, // ordered oldest-first (chronological). Used to build offline reports from // real adventure log entries written by the offline simulator. func (s *LogStore) GetSince(ctx context.Context, heroID int64, since time.Time, limit int) ([]LogEntry, error) { if limit <= 0 { limit = 200 } if limit > 500 { limit = 500 } rows, err := s.pool.Query(ctx, ` SELECT id, hero_id, message, created_at, event_code, event_args FROM adventure_log WHERE hero_id = $1 AND created_at > $2 ORDER BY created_at ASC LIMIT $3 `, heroID, since, limit) if err != nil { return nil, fmt.Errorf("get log since: %w", err) } defer rows.Close() var entries []LogEntry for rows.Next() { var e LogEntry var code *string var argsBytes []byte if err := rows.Scan(&e.ID, &e.HeroID, &e.Message, &e.CreatedAt, &code, &argsBytes); err != nil { return nil, fmt.Errorf("scan log entry: %w", err) } entries = append(entries, logEntryFromScan(e.ID, e.HeroID, e.Message, e.CreatedAt, code, argsBytes)) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("log since rows: %w", err) } if entries == nil { entries = []LogEntry{} } return entries, nil } // GetRecent returns the most recent log entries for a hero, ordered newest-first. func (s *LogStore) GetRecent(ctx context.Context, heroID int64, limit int) ([]LogEntry, error) { if limit <= 0 { limit = 50 } if limit > 200 { limit = 200 } rows, err := s.pool.Query(ctx, ` SELECT id, hero_id, message, created_at, event_code, event_args FROM adventure_log WHERE hero_id = $1 ORDER BY created_at DESC LIMIT $2 `, heroID, limit) if err != nil { return nil, fmt.Errorf("get recent log: %w", err) } defer rows.Close() var entries []LogEntry for rows.Next() { var e LogEntry var code *string var argsBytes []byte if err := rows.Scan(&e.ID, &e.HeroID, &e.Message, &e.CreatedAt, &code, &argsBytes); err != nil { return nil, fmt.Errorf("scan log entry: %w", err) } entries = append(entries, logEntryFromScan(e.ID, e.HeroID, e.Message, e.CreatedAt, code, argsBytes)) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("log entries rows: %w", err) } if entries == nil { entries = []LogEntry{} } return entries, nil }