From 00bd5ab3268df5492f174e9506e11d8be561284e Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 3 Apr 2026 14:58:52 -0500 Subject: [PATCH 1/3] Add user enrichment columns and user_search config hook - Migration adds avatar_url, title, team, notification_preferences to coplan_users - User model defaults notification_preferences to {} via after_initialize - Configuration gets user_search attr_accessor for lambda-based search hook - New API endpoint GET /api/v1/users/search?q=query with hook delegation and LIKE fallback - ActiveAdmin registration updated to show/permit new fields - Model and request specs for all new functionality Part of CoPlan v2 Roadmap (Phase 1, item 4). Amp-Thread-ID: https://ampcode.com/threads/T-019d54e9-adf1-746a-861d-3410a5d1d55c Co-authored-by: Amp --- app/admin/users.rb | 7 +- ..._profile_fields_to_coplan_users.co_plan.rb | 9 +++ db/schema.rb | 6 +- .../coplan/api/v1/users_controller.rb | 37 ++++++++++ engine/app/models/coplan/user.rb | 3 +- engine/config/routes.rb | 3 + ...0000_add_profile_fields_to_coplan_users.rb | 8 ++ engine/lib/coplan/configuration.rb | 1 + spec/models/user_spec.rb | 48 ++++++++++++ spec/requests/api/v1/users_spec.rb | 74 +++++++++++++++++++ 10 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20260403195620_add_profile_fields_to_coplan_users.co_plan.rb create mode 100644 engine/app/controllers/coplan/api/v1/users_controller.rb create mode 100644 engine/db/migrate/20260403000000_add_profile_fields_to_coplan_users.rb create mode 100644 spec/models/user_spec.rb create mode 100644 spec/requests/api/v1/users_spec.rb diff --git a/app/admin/users.rb b/app/admin/users.rb index 48baafb..8ff8fb9 100644 --- a/app/admin/users.rb +++ b/app/admin/users.rb @@ -1,11 +1,13 @@ ActiveAdmin.register CoPlan::User, as: "User" do - permit_params :name, :email, :admin + permit_params :name, :email, :admin, :avatar_url, :title, :team index do selectable_column id_column column :name column :email + column :title + column :team column :admin column :created_at actions @@ -17,6 +19,9 @@ row :external_id row :name row :email + row :avatar_url + row :title + row :team row :admin row :created_at row :updated_at diff --git a/db/migrate/20260403195620_add_profile_fields_to_coplan_users.co_plan.rb b/db/migrate/20260403195620_add_profile_fields_to_coplan_users.co_plan.rb new file mode 100644 index 0000000..0ff8867 --- /dev/null +++ b/db/migrate/20260403195620_add_profile_fields_to_coplan_users.co_plan.rb @@ -0,0 +1,9 @@ +# This migration comes from co_plan (originally 20260403000000) +class AddProfileFieldsToCoplanUsers < ActiveRecord::Migration[8.1] + def change + add_column :coplan_users, :avatar_url, :string + add_column :coplan_users, :title, :string + add_column :coplan_users, :team, :string + add_column :coplan_users, :notification_preferences, :json + end +end diff --git a/db/schema.rb b/db/schema.rb index 4f98ca6..0e5a8a1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_27_170858) do +ActiveRecord::Schema[8.1].define(version: 2026_04_03_195620) do create_table "active_admin_comments", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "author_id" t.string "author_type" @@ -198,11 +198,15 @@ create_table "coplan_users", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.boolean "admin", default: false, null: false + t.string "avatar_url" t.datetime "created_at", null: false t.string "email" t.string "external_id", null: false t.json "metadata" t.string "name", null: false + t.json "notification_preferences" + t.string "team" + t.string "title" t.datetime "updated_at", null: false t.index ["email"], name: "index_coplan_users_on_email", unique: true t.index ["external_id"], name: "index_coplan_users_on_external_id", unique: true diff --git a/engine/app/controllers/coplan/api/v1/users_controller.rb b/engine/app/controllers/coplan/api/v1/users_controller.rb new file mode 100644 index 0000000..d56c116 --- /dev/null +++ b/engine/app/controllers/coplan/api/v1/users_controller.rb @@ -0,0 +1,37 @@ +module CoPlan + module Api + module V1 + class UsersController < BaseController + def search + query = params[:q].to_s.strip + if query.blank? + return render json: [] + end + + if CoPlan.configuration.user_search + results = CoPlan.configuration.user_search.call(query) + render json: results + else + users = CoPlan::User + .where("name LIKE :q OR email LIKE :q", q: "%#{query}%") + .limit(20) + render json: users.map { |u| user_json(u) } + end + end + + private + + def user_json(user) + { + id: user.id, + name: user.name, + email: user.email, + avatar_url: user.avatar_url, + title: user.title, + team: user.team + } + end + end + end + end +end diff --git a/engine/app/models/coplan/user.rb b/engine/app/models/coplan/user.rb index 0ee945d..3fab34c 100644 --- a/engine/app/models/coplan/user.rb +++ b/engine/app/models/coplan/user.rb @@ -10,9 +10,10 @@ class User < ApplicationRecord validates :email, uniqueness: true, allow_nil: true after_initialize { self.metadata ||= {} } + after_initialize { self.notification_preferences ||= {} } def self.ransackable_attributes(auth_object = nil) - %w[id external_id name email admin created_at updated_at] + %w[id external_id name email admin avatar_url title team created_at updated_at] end def self.ransackable_associations(auth_object = nil) diff --git a/engine/config/routes.rb b/engine/config/routes.rb index 032bebb..617e1b5 100644 --- a/engine/config/routes.rb +++ b/engine/config/routes.rb @@ -21,6 +21,9 @@ namespace :api do namespace :v1 do + resources :users, only: [] do + get :search, on: :collection + end resources :plans, only: [:index, :show, :create, :update] do get :versions, on: :member get :comments, on: :member diff --git a/engine/db/migrate/20260403000000_add_profile_fields_to_coplan_users.rb b/engine/db/migrate/20260403000000_add_profile_fields_to_coplan_users.rb new file mode 100644 index 0000000..828a6bf --- /dev/null +++ b/engine/db/migrate/20260403000000_add_profile_fields_to_coplan_users.rb @@ -0,0 +1,8 @@ +class AddProfileFieldsToCoplanUsers < ActiveRecord::Migration[8.1] + def change + add_column :coplan_users, :avatar_url, :string + add_column :coplan_users, :title, :string + add_column :coplan_users, :team, :string + add_column :coplan_users, :notification_preferences, :json + end +end diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index a52801a..36b815f 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -7,6 +7,7 @@ class Configuration attr_accessor :onboarding_banner attr_accessor :agent_auth_instructions attr_accessor :agent_curl_prefix + attr_accessor :user_search def initialize @authenticate = nil diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 0000000..6cc5ddf --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,48 @@ +require "rails_helper" + +RSpec.describe CoPlan::User, type: :model do + it "is valid with valid attributes" do + user = create(:coplan_user) + expect(user).to be_valid + end + + it "requires external_id" do + user = build(:coplan_user, external_id: nil) + expect(user).not_to be_valid + expect(user.errors[:external_id]).to include("can't be blank") + end + + it "requires name" do + user = build(:coplan_user, name: nil) + expect(user).not_to be_valid + expect(user.errors[:name]).to include("can't be blank") + end + + it "defaults notification_preferences to empty hash" do + user = CoPlan::User.new + expect(user.notification_preferences).to eq({}) + end + + it "defaults metadata to empty hash" do + user = CoPlan::User.new + expect(user.metadata).to eq({}) + end + + it "persists profile fields" do + user = create(:coplan_user, + avatar_url: "https://example.com/avatar.png", + title: "Staff Engineer", + team: "Platform" + ) + user.reload + expect(user.avatar_url).to eq("https://example.com/avatar.png") + expect(user.title).to eq("Staff Engineer") + expect(user.team).to eq("Platform") + end + + it "persists notification_preferences" do + user = create(:coplan_user, notification_preferences: { "slack" => true }) + user.reload + expect(user.notification_preferences).to eq("slack" => true) + end +end diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb new file mode 100644 index 0000000..8b48b19 --- /dev/null +++ b/spec/requests/api/v1/users_spec.rb @@ -0,0 +1,74 @@ +require "rails_helper" + +RSpec.describe "Api::V1::Users", type: :request do + let(:user) { create(:coplan_user) } + let(:api_token) { create(:api_token, user: user, raw_token: "test-token-users") } + let(:headers) { { "Authorization" => "Bearer test-token-users" } } + + before { api_token } + + describe "GET /api/v1/users/search" do + it "requires authentication" do + get search_api_v1_users_path, params: { q: "alice" } + expect(response).to have_http_status(:unauthorized) + end + + it "returns empty array for blank query" do + get search_api_v1_users_path, params: { q: "" }, headers: headers + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body)).to eq([]) + end + + it "returns empty array for missing query" do + get search_api_v1_users_path, headers: headers + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body)).to eq([]) + end + + context "fallback LIKE search" do + let!(:alice) { create(:coplan_user, name: "Alice Smith", email: "alice@example.com", title: "Engineer", team: "Platform") } + let!(:bob) { create(:coplan_user, name: "Bob Jones", email: "bob@example.com") } + + it "searches by name" do + get search_api_v1_users_path, params: { q: "Alice" }, headers: headers + results = JSON.parse(response.body) + expect(results.length).to eq(1) + expect(results.first["name"]).to eq("Alice Smith") + expect(results.first["title"]).to eq("Engineer") + expect(results.first["team"]).to eq("Platform") + end + + it "searches by email" do + get search_api_v1_users_path, params: { q: "bob@" }, headers: headers + results = JSON.parse(response.body) + expect(results.length).to eq(1) + expect(results.first["name"]).to eq("Bob Jones") + end + + it "returns user JSON with expected fields" do + get search_api_v1_users_path, params: { q: "Alice" }, headers: headers + result = JSON.parse(response.body).first + expect(result.keys).to match_array(%w[id name email avatar_url title team]) + end + end + + context "with user_search hook configured" do + let(:hook_results) { [{ id: "ext-1", name: "Hooked User", email: "hooked@example.com" }] } + + before do + CoPlan.configuration.user_search = ->(query) { hook_results } + end + + after do + CoPlan.configuration.user_search = nil + end + + it "delegates to the hook" do + get search_api_v1_users_path, params: { q: "hooked" }, headers: headers + results = JSON.parse(response.body) + expect(results.length).to eq(1) + expect(results.first["name"]).to eq("Hooked User") + end + end + end +end From 5dead012f17d1cb2fe774bd61bb992bbdfc1db8a Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 3 Apr 2026 15:11:38 -0500 Subject: [PATCH 2/3] Fix review findings: sanitize LIKE wildcards, filter hook results through user_json - Use sanitize_sql_like to prevent wildcard injection in search query - Unify both hook and fallback branches through user_json serialization - ALLOWED_FIELDS constant ensures only safe fields are returned - Spec verifies extra fields from hook results are stripped Amp-Thread-ID: https://ampcode.com/threads/T-019d54e9-adf1-746a-861d-3410a5d1d55c Co-authored-by: Amp --- .../coplan/api/v1/users_controller.rb | 27 +++++++++---------- spec/requests/api/v1/users_spec.rb | 6 +++-- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/engine/app/controllers/coplan/api/v1/users_controller.rb b/engine/app/controllers/coplan/api/v1/users_controller.rb index d56c116..27774ef 100644 --- a/engine/app/controllers/coplan/api/v1/users_controller.rb +++ b/engine/app/controllers/coplan/api/v1/users_controller.rb @@ -8,28 +8,27 @@ def search return render json: [] end - if CoPlan.configuration.user_search - results = CoPlan.configuration.user_search.call(query) - render json: results + users = if CoPlan.configuration.user_search + CoPlan.configuration.user_search.call(query) else - users = CoPlan::User - .where("name LIKE :q OR email LIKE :q", q: "%#{query}%") + sanitized = CoPlan::User.sanitize_sql_like(query) + CoPlan::User + .where("name LIKE :q OR email LIKE :q", q: "%#{sanitized}%") .limit(20) - render json: users.map { |u| user_json(u) } end + render json: users.map { |u| user_json(u) } end private + ALLOWED_FIELDS = %i[id name email avatar_url title team].freeze + def user_json(user) - { - id: user.id, - name: user.name, - email: user.email, - avatar_url: user.avatar_url, - title: user.title, - team: user.team - } + if user.respond_to?(:id) + ALLOWED_FIELDS.to_h { |f| [f, user.public_send(f)] } + else + ALLOWED_FIELDS.to_h { |f| [f, user[f]] } + end end end end diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb index 8b48b19..4d1ddeb 100644 --- a/spec/requests/api/v1/users_spec.rb +++ b/spec/requests/api/v1/users_spec.rb @@ -53,7 +53,7 @@ end context "with user_search hook configured" do - let(:hook_results) { [{ id: "ext-1", name: "Hooked User", email: "hooked@example.com" }] } + let(:hook_results) { [{ id: "ext-1", name: "Hooked User", email: "hooked@example.com", secret: "should-not-leak" }] } before do CoPlan.configuration.user_search = ->(query) { hook_results } @@ -63,11 +63,13 @@ CoPlan.configuration.user_search = nil end - it "delegates to the hook" do + it "delegates to the hook and filters fields" do get search_api_v1_users_path, params: { q: "hooked" }, headers: headers results = JSON.parse(response.body) expect(results.length).to eq(1) expect(results.first["name"]).to eq("Hooked User") + expect(results.first.keys).to match_array(%w[id name email avatar_url title team]) + expect(results.first).not_to have_key("secret") end end end From 0fbfed6f8507884de5ff61b5dc53e822ed0c80c5 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 3 Apr 2026 15:20:16 -0500 Subject: [PATCH 3/3] Document user_search config hook with inline comments and initializer example Amp-Thread-ID: https://ampcode.com/threads/T-019d54e9-adf1-746a-861d-3410a5d1d55c Co-authored-by: Amp --- config/initializers/coplan.rb | 6 ++++++ engine/lib/coplan/configuration.rb | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/config/initializers/coplan.rb b/config/initializers/coplan.rb index 7b3c37e..f0be9e2 100644 --- a/config/initializers/coplan.rb +++ b/config/initializers/coplan.rb @@ -18,6 +18,12 @@ config.ai_api_key = Rails.application.credentials.dig(:openai, :api_key) || ENV["OPENAI_API_KEY"] config.ai_model = "gpt-4o" + # Optional: delegate user search to an external directory (e.g., People API). + # When unset, /api/v1/users/search queries the local coplan_users table. + # config.user_search = ->(query) { + # PeopleApi.search(query).map { |p| { id: p.id, name: p.name, email: p.email } } + # } + config.notification_handler = ->(event, payload) { case event when :comment_created diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index 36b815f..24662e5 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -7,6 +7,16 @@ class Configuration attr_accessor :onboarding_banner attr_accessor :agent_auth_instructions attr_accessor :agent_curl_prefix + + # Lambda for user search used by the /api/v1/users/search endpoint. + # Accepts a query string, returns an array of hashes with keys: + # :id, :name, :email, :avatar_url, :title, :team + # When nil (default), falls back to LIKE search on local coplan_users table. + # + # Example: + # config.user_search = ->(query) { + # PeopleApi.search(query).map { |p| { id: p.id, name: p.name, email: p.email } } + # } attr_accessor :user_search def initialize