package store import ( "context" "database/sql" "errors" "fmt" "strings" "time" "github.com/google/uuid" ) // ErrLastAdmin is returned when demoting the only remaining admin. var ErrLastAdmin = errors.New("cannot demote the last admin") // Role is a user privilege level stored in users.role. type Role string const ( RoleUser Role = "user" RoleAdmin Role = "admin" ) // User is an account row. Methods run SQL against db. type User struct { ID string Username string Name string Role Role AvatarURL string State string CreatedAt string PasswordHash string db *sql.DB } // NewUser returns a User bound to db (not yet inserted). func NewUser(db *sql.DB) *User { return &User{db: db} } func (u *User) Admin() bool { return u != nil && u.Role == RoleAdmin } func NormalizeUsername(s string) string { return strings.ToLower(strings.TrimSpace(s)) } // Create inserts the user. Sets ID, Name, and CreatedAt when empty. func (u *User) Create(ctx context.Context) error { if u == nil || u.db == nil { return fmt.Errorf("user: no database") } if u.Role != RoleUser && u.Role != RoleAdmin { return fmt.Errorf("invalid role") } u.Username = NormalizeUsername(u.Username) if u.ID == "" { u.ID = uuid.NewString() } if u.Name == "" { u.Name = u.Username } if u.CreatedAt == "" { u.CreatedAt = time.Now().UTC().Format(time.RFC3339) } _, err := u.db.ExecContext(ctx, `INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) VALUES ($1, $2, $3, $4, $5, '', '', $6)`, u.ID, u.Username, u.Name, u.PasswordHash, string(u.Role), u.CreatedAt) return err } // SetRole updates this user's role (last-admin safe). func (u *User) SetRole(ctx context.Context, role Role) error { if u == nil || u.db == nil { return fmt.Errorf("user: no database") } if role != RoleUser && role != RoleAdmin { return fmt.Errorf("invalid role") } tx, err := u.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() var current string err = tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id = $1`, u.ID).Scan(¤t) if err != nil { return err } if Role(current) == RoleAdmin && role == RoleUser { var n int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = $1`, string(RoleAdmin)).Scan(&n); err != nil { return err } if n <= 1 { return ErrLastAdmin } } res, err := tx.ExecContext(ctx, `UPDATE users SET role = $1 WHERE id = $2`, string(role), u.ID) if err != nil { return err } aff, err := res.RowsAffected() if err != nil { return err } if aff == 0 { return sql.ErrNoRows } if err := tx.Commit(); err != nil { return err } u.Role = role return nil } // SaveProfile writes State and optionally AvatarURL. func (u *User) SaveProfile(ctx context.Context) error { if u == nil || u.db == nil { return fmt.Errorf("user: no database") } u.State = strings.TrimSpace(u.State) if u.AvatarURL == "" { _, err := u.db.ExecContext(ctx, `UPDATE users SET state = $1 WHERE id = $2`, u.State, u.ID) return err } _, err := u.db.ExecContext(ctx, `UPDATE users SET state = $1, avatar_url = $2 WHERE id = $3`, u.State, u.AvatarURL, u.ID) return err } func CountAdmins(ctx context.Context, db *sql.DB) (int, error) { var n int err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = $1`, string(RoleAdmin)).Scan(&n) return n, err } func ListUsers(ctx context.Context, db *sql.DB) ([]User, error) { rows, err := db.QueryContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at FROM users ORDER BY created_at ASC`) if err != nil { return nil, err } defer rows.Close() var out []User for rows.Next() { var u User var role string if err := rows.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt); err != nil { return nil, err } u.Role = Role(role) u.db = db out = append(out, u) } return out, rows.Err() } func UserByID(ctx context.Context, db *sql.DB, id string) (*User, error) { return scanUser(db, db.QueryRowContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at FROM users WHERE id = $1`, id), false) } func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, error) { return scanUser(db, db.QueryRowContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at, password_hash FROM users WHERE username = $1`, NormalizeUsername(username)), true) } func scanUser(db *sql.DB, row *sql.Row, withSecrets bool) (*User, error) { var u User var role string var err error if withSecrets { err = row.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt, &u.PasswordHash) } else { err = row.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt) } if err != nil { return nil, err } u.Role = Role(role) u.db = db return &u, nil }