package postgres import ( "context" "database/sql" "fmt" "time" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) 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, 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.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 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) { 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) { 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) { 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 { b := NewUpdate("users") for k, v := range fields { if k == "settings" { b.SetJSON(k, v) } else { b.Set(k, v) } } if !b.HasSets() { return nil } b.Where("id", id) _, err := b.Exec(DB) return err } func (s *UserStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, "DELETE FROM users WHERE id = $1", id) return err } func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.User, int, error) { b := NewSelect(userListCols, "users") if opts.Sort == "" { b.OrderBy("username", "ASC") } b.Paginate(opts) var total int DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&total) q, args := b.Build() rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, 0, err } defer rows.Close() var result []models.User for rows.Next() { var u models.User 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(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() } 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 } // ── 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) 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) 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) 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) 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'") return err } // ── Internal ──────────────────────────────── func scanOneUser(ctx context.Context, query string, args ...interface{}) (*models.User, error) { var u models.User 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.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 }