package store import ( "context" "database/sql" "errors" "fmt" "time" ) // CreateUser inserts a new user. The caller is responsible for // generating an ID (typically a ULID) and hashing the password. func (s *Store) CreateUser(ctx context.Context, u User) error { _, err := s.db.ExecContext(ctx, `INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)`, u.ID, u.Username, u.PasswordHash, string(u.Role), u.CreatedAt.UTC().Format(time.RFC3339Nano)) if err != nil { return fmt.Errorf("store: create user: %w", err) } return nil } // GetUserByUsername looks up a user by their (case-sensitive) username. // Returns ErrNotFound if no row matches. func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) { row := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash, role, created_at, last_login_at FROM users WHERE username = ?`, username) return scanUser(row) } // GetUserByID looks up a user by id. Returns ErrNotFound on miss. func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) { row := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash, role, created_at, last_login_at FROM users WHERE id = ?`, id) return scanUser(row) } // ListUsers returns every user, sorted by username. Used by surfaces // that need to render a user-id → username map (audit log filter, // "ack'd by" projections). func (s *Store) ListUsers(ctx context.Context) ([]User, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, username, password_hash, role, created_at, last_login_at FROM users ORDER BY username`) if err != nil { return nil, fmt.Errorf("store: list users: %w", err) } defer func() { _ = rows.Close() }() var out []User for rows.Next() { var u User var role string var lastLogin sql.NullString var created string if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &created, &lastLogin); err != nil { return nil, fmt.Errorf("store: scan user row: %w", err) } u.Role = Role(role) t, _ := time.Parse(time.RFC3339Nano, created) u.CreatedAt = t if lastLogin.Valid { t, _ := time.Parse(time.RFC3339Nano, lastLogin.String) u.LastLoginAt = &t } out = append(out, u) } return out, rows.Err() } // CountUsers returns the total number of user rows. The first-run // bootstrap uses this to detect a fresh install. func (s *Store) CountUsers(ctx context.Context) (int, error) { var n int if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil { return 0, fmt.Errorf("store: count users: %w", err) } return n, nil } // MarkUserLogin records a successful authentication. func (s *Store) MarkUserLogin(ctx context.Context, id string, when time.Time) error { _, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, when.UTC().Format(time.RFC3339Nano), id) if err != nil { return fmt.Errorf("store: mark login: %w", err) } return nil } func scanUser(row *sql.Row) (*User, error) { var u User var role string var lastLogin sql.NullString var created string if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &created, &lastLogin); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, fmt.Errorf("store: scan user: %w", err) } u.Role = Role(role) t, err := time.Parse(time.RFC3339Nano, created) if err != nil { return nil, fmt.Errorf("store: parse created_at: %w", err) } u.CreatedAt = t if lastLogin.Valid { t, err := time.Parse(time.RFC3339Nano, lastLogin.String) if err != nil { return nil, fmt.Errorf("store: parse last_login_at: %w", err) } u.LastLoginAt = &t } return &u, nil }