package store import ( "context" "fmt" "time" "github.com/jackc/pgx/v5" ) // CommandLogEntry is one row of the command audit log, joined with the sender's // name for display. type CommandLogEntry struct { ID int64 RepeaterID int64 UserID *int64 SenderName string // username snapshotted at send time; "(deleted)" if unknown CommandText string SentAt time.Time AckReceived bool ResponseText *string } // LogCommand records a command send attempt within a session and returns its // id. commandID may be 0 (stored NULL) when there's no catalog match. func (s *Store) LogCommand(ctx context.Context, repeaterID, userID, sessionID, commandID int64, text string) (int64, error) { var id int64 err := s.pool.QueryRow(ctx, ` INSERT INTO command_log (repeater_id, user_id, session_id, command_id, command_text, sender_username) VALUES ($1, $2, $3, $4, $5, (SELECT username FROM users WHERE id = $2)) RETURNING id`, repeaterID, userID, sessionID, nullID(commandID), text).Scan(&id) if err != nil { return 0, fmt.Errorf("log command: %w", err) } return id, nil } func nullID(id int64) *int64 { if id == 0 { return nil } return &id } // MarkCommandReply records that a reply was received for a logged command. func (s *Store) MarkCommandReply(ctx context.Context, logID int64, response string) error { _, err := s.pool.Exec(ctx, `UPDATE command_log SET ack_received = TRUE, response_text = $2 WHERE id = $1`, logID, response) if err != nil { return fmt.Errorf("mark command reply: %w", err) } return nil } // CommandSession groups command-log entries by the console session they were // sent in. type CommandSession struct { ID int64 // SenderName is who held the session: their current display name (or username), // falling back to the username snapshotted at session start if they've since // been deleted. SenderName string // SenderUsername is the sender's current username, for linking to their profile // (/u/{username}); empty when the user has been deleted (no profile to link). SenderUsername string StartedAt time.Time EndedAt *time.Time Entries []*CommandLogEntry // newest first, matching the newest-first session order } // CommandLogPageSize is the number of console sessions shown per log page. const CommandLogPageSize = 25 // CommandLogCursor is the keyset position in a repeater's session log: the // (started_at, id) of the last session on the current page. The next page seeks // strictly older than it. type CommandLogCursor struct { StartedAt time.Time ID int64 } // ListCommandLogSessionsPage returns one keyset page of a repeater's command log // grouped into console sessions, newest session first. before is the cursor from // the previous page, or nil for the first page. It returns the sessions (each // with its entries newest first) and whether older sessions remain. // // Sessions are the pagination unit (the page renders one card per session), so // a session is never split across pages — and the per-page work is bounded by // the page size, unlike the old fixed 500-row cap that silently hid older // history. Both the session seek and the entry fetch ride existing indexes. func (s *Store) ListCommandLogSessionsPage(ctx context.Context, repeaterID int64, before *CommandLogCursor) ([]*CommandSession, bool, error) { first := before == nil var curStarted time.Time var curID int64 if !first { curStarted, curID = before.StartedAt, before.ID } // Fetch one extra session to detect whether an older page exists. Only // sessions that actually logged a command are shown (matching the inner-join // behavior of the previous query). headers, err := s.pool.Query(ctx, ` SELECT cs.id, cs.started_at, cs.ended_at, COALESCE(NULLIF(u.display_name, ''), u.username, cs.sender_username, '(deleted)'), COALESCE(u.username, '') FROM console_sessions cs LEFT JOIN users u ON u.id = cs.user_id WHERE cs.repeater_id = $1 AND ($2 OR (cs.started_at, cs.id) < ($3, $4)) AND EXISTS (SELECT 1 FROM command_log l WHERE l.session_id = cs.id) ORDER BY cs.started_at DESC, cs.id DESC LIMIT $5`, repeaterID, first, curStarted, curID, CommandLogPageSize+1) if err != nil { return nil, false, fmt.Errorf("list command sessions: %w", err) } groups, err := collectRows(headers, func(r pgx.Row) (*CommandSession, error) { var g CommandSession err := r.Scan(&g.ID, &g.StartedAt, &g.EndedAt, &g.SenderName, &g.SenderUsername) return &g, err }) if err != nil { return nil, false, fmt.Errorf("scan session: %w", err) } hasMore := len(groups) > CommandLogPageSize if hasMore { groups = groups[:CommandLogPageSize] } if len(groups) == 0 { return groups, false, nil } // Index the page's sessions so the entry fetch can attach rows by session. byID := make(map[int64]*CommandSession, len(groups)) ids := make([]int64, 0, len(groups)) for _, g := range groups { byID[g.ID] = g ids = append(ids, g.ID) } // Load all entries for the page's sessions at once, newest first so each // session's slice matches the newest-first session order (consistent direction // throughout the log). rows, err := s.pool.Query(ctx, ` SELECT l.id, l.session_id, l.command_text, l.sent_at, l.ack_received, l.response_text FROM command_log l WHERE l.session_id = ANY($1) ORDER BY l.sent_at DESC, l.id DESC`, ids) if err != nil { return nil, false, fmt.Errorf("list command entries: %w", err) } defer rows.Close() for rows.Next() { var e CommandLogEntry var sessionID int64 if err := rows.Scan(&e.ID, &sessionID, &e.CommandText, &e.SentAt, &e.AckReceived, &e.ResponseText); err != nil { return nil, false, fmt.Errorf("scan entry: %w", err) } if g := byID[sessionID]; g != nil { g.Entries = append(g.Entries, &e) } } return groups, hasMore, rows.Err() } // OwnerCommandLogEntry is a recent command across all repeaters a user owns, // joined with the repeater's name and the sender's name (for the dashboard). type OwnerCommandLogEntry struct { RepeaterID int64 RepeaterPublicID string RepeaterName string SenderName string SenderUsername string // current username for a profile link; empty if deleted CommandText string SentAt time.Time AckReceived bool ResponseText *string } // ListRecentCommandsForOwner returns the most recent commands run on any // repeater owned by ownerID, newest first, bounded by limit. func (s *Store) ListRecentCommandsForOwner(ctx context.Context, ownerID int64, limit int) ([]OwnerCommandLogEntry, error) { rows, err := s.pool.Query(ctx, ` SELECT l.repeater_id, r.public_id, r.name, COALESCE(NULLIF(u.display_name, ''), u.username, l.sender_username, '(deleted)'), COALESCE(u.username, ''), l.command_text, l.sent_at, l.ack_received, l.response_text FROM command_log l JOIN repeaters r ON r.id = l.repeater_id LEFT JOIN users u ON u.id = l.user_id WHERE r.owner_id = $1 ORDER BY l.sent_at DESC LIMIT $2`, ownerID, limit) if err != nil { return nil, fmt.Errorf("list owner commands: %w", err) } return collectRows(rows, func(r pgx.Row) (OwnerCommandLogEntry, error) { var e OwnerCommandLogEntry err := r.Scan(&e.RepeaterID, &e.RepeaterPublicID, &e.RepeaterName, &e.SenderName, &e.SenderUsername, &e.CommandText, &e.SentAt, &e.AckReceived, &e.ResponseText) return e, err }) } // ListCommandLog returns recent log entries for a repeater, newest first. func (s *Store) ListCommandLog(ctx context.Context, repeaterID int64, limit int) ([]*CommandLogEntry, error) { rows, err := s.pool.Query(ctx, ` SELECT l.id, l.repeater_id, l.user_id, COALESCE(l.sender_username, '(deleted)'), l.command_text, l.sent_at, l.ack_received, l.response_text FROM command_log l WHERE l.repeater_id = $1 ORDER BY l.sent_at DESC LIMIT $2`, repeaterID, limit) if err != nil { return nil, fmt.Errorf("list command log: %w", err) } return collectRows(rows, func(r pgx.Row) (*CommandLogEntry, error) { var e CommandLogEntry err := r.Scan(&e.ID, &e.RepeaterID, &e.UserID, &e.SenderName, &e.CommandText, &e.SentAt, &e.AckReceived, &e.ResponseText) return &e, err }) }