Changeset 0.24.0 (#156)

This commit is contained in:
2026-03-07 11:27:24 +00:00
parent 2dc4514a57
commit a63728a481
23 changed files with 1850 additions and 385 deletions

View File

@@ -14,77 +14,43 @@ type UserStore struct{}
func NewUserStore() *UserStore { return &UserStore{} }
const userCols = `id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at,
auth_source, external_id, handle`
const userListCols = `id, username, email, display_name, avatar_url, role, is_active,
settings, created_at, updated_at, last_login_at, auth_source, external_id, handle`
func (s *UserStore) Create(ctx context.Context, u *models.User) error {
if u.AuthSource == "" {
u.AuthSource = "builtin"
}
return DB.QueryRowContext(ctx, `
INSERT INTO users (username, email, password_hash, display_name, role, is_active, settings)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO users (username, email, password_hash, display_name, role, is_active, settings, auth_source, external_id, handle)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, created_at, updated_at`,
u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, ToJSON(u.Settings),
u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive,
ToJSON(u.Settings), u.AuthSource, u.ExternalID, u.Handle,
).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
}
func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error) {
return s.getBy(ctx, "id", id)
return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE id = $1", userCols), id)
}
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE LOWER(username) = LOWER($1)`, username).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(username) = LOWER($1)", userCols), username)
}
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*models.User, error) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE LOWER(email) = LOWER($1)`, email).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(email) = LOWER($1)", userCols), email)
}
func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User, error) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE LOWER(username) = LOWER($1) OR LOWER(email) = LOWER($1)`, login).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(username) = LOWER($1) OR LOWER(email) = LOWER($1)", userCols), login)
}
func (s *UserStore) GetByHandle(ctx context.Context, handle string) (*models.User, error) {
return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(handle) = LOWER($1)", userCols), handle)
}
func (s *UserStore) GetByExternalID(ctx context.Context, authSource, externalID string) (*models.User, error) {
return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE auth_source = $1 AND external_id = $2", userCols), authSource, externalID)
}
func (s *UserStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
@@ -110,16 +76,12 @@ func (s *UserStore) Delete(ctx context.Context, id string) error {
}
func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.User, int, error) {
b := NewSelect(
"id, username, email, display_name, avatar_url, role, is_active, settings, created_at, updated_at, last_login_at",
"users",
)
b := NewSelect(userListCols, "users")
if opts.Sort == "" {
b.OrderBy("username", "ASC")
}
b.Paginate(opts)
// Count
var total int
DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&total)
@@ -133,16 +95,19 @@ func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.
var result []models.User
for rows.Next() {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := rows.Scan(&u.ID, &u.Username, &u.Email, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt)
if err != nil {
var dn, av sql.NullString
var extID, hdl sql.NullString
var sj []byte
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av,
&u.Role, &u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
&u.AuthSource, &extID, &hdl); err != nil {
return nil, 0, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
u.DisplayName = NullableString(dn)
u.AvatarURL = NullableString(av)
if extID.Valid { u.ExternalID = &extID.String }
u.Handle = NullableString(hdl)
ScanJSON(sj, &u.Settings)
result = append(result, u)
}
return result, total, rows.Err()
@@ -152,7 +117,6 @@ func (s *UserStore) UpdateLastLogin(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "UPDATE users SET last_login_at = NOW() WHERE id = $1", id)
return err
}
func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error {
_, err := DB.ExecContext(ctx, "UPDATE users SET is_active = $1 WHERE id = $2", active, id)
return err
@@ -161,57 +125,47 @@ func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error
// ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt)
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt)
return err
}
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
var userID string
err := DB.QueryRowContext(ctx, `
SELECT user_id FROM refresh_tokens
WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`,
tokenHash).Scan(&userID)
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, tokenHash).Scan(&userID)
return userID, err
}
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
_, err := DB.ExecContext(ctx,
"UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash)
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash)
return err
}
func (s *UserStore) RevokeAllRefreshTokens(ctx context.Context, userID string) error {
_, err := DB.ExecContext(ctx,
"UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", userID)
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", userID)
return err
}
func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
_, err := DB.ExecContext(ctx,
"DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'")
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'")
return err
}
// ── Internal ────────────────────────────────
func (s *UserStore) getBy(ctx context.Context, col, val string) (*models.User, error) {
func scanOneUser(ctx context.Context, query string, args ...interface{}) (*models.User, error) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, fmt.Sprintf(`
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE %s = $1`, col), val).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
var dn, av, ph sql.NullString
var extID, hdl sql.NullString
var sj []byte
err := DB.QueryRowContext(ctx, query, args...).Scan(
&u.ID, &u.Username, &u.Email, &ph, &dn, &av,
&u.Role, &u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
&u.AuthSource, &extID, &hdl,
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
u.PasswordHash = NullableString(ph)
u.DisplayName = NullableString(dn)
u.AvatarURL = NullableString(av)
if extID.Valid { u.ExternalID = &extID.String }
u.Handle = NullableString(hdl)
ScanJSON(sj, &u.Settings)
return &u, nil
}