Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/admin/users.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions config/initializers/coplan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 36 additions & 0 deletions engine/app/controllers/coplan/api/v1/users_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
module CoPlan
module Api
module V1
class UsersController < BaseController
def search
query = params[:q].to_s.strip
if query.blank?
return render json: []
end

users = if CoPlan.configuration.user_search
CoPlan.configuration.user_search.call(query)
else
sanitized = CoPlan::User.sanitize_sql_like(query)
CoPlan::User
.where("name LIKE :q OR email LIKE :q", q: "%#{sanitized}%")
.limit(20)
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)
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
end
end
3 changes: 2 additions & 1 deletion engine/app/models/coplan/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions engine/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions engine/lib/coplan/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ class Configuration
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
@authenticate = nil
@ai_base_url = "https://api.openai.com/v1"
Expand Down
48 changes: 48 additions & 0 deletions spec/models/user_spec.rb
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions spec/requests/api/v1/users_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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", secret: "should-not-leak" }] }

before do
CoPlan.configuration.user_search = ->(query) { hook_results }
end

after do
CoPlan.configuration.user_search = nil
end

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
end
Loading