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
9 changes: 6 additions & 3 deletions app/jobs/slack_notification_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@ def perform(comment_thread_id:)

thread = CoPlan::CommentThread.find(comment_thread_id)
plan = thread.plan
plan_author = plan.created_by_user
coplan_author = plan.created_by_user
first_comment = thread.comments.order(:created_at, :id).first

return unless first_comment
return if first_comment.author_type == "human" && first_comment.author_id == plan_author.id
return if first_comment.author_type == "human" && first_comment.author_id == coplan_author.id

host_user = User.find_by(id: coplan_author.external_id)
return unless host_user

text = compose_message(thread, plan)
SlackClient.send_dm(email: plan_author.email, text: text)
SlackClient.send_dm(email: host_user.email, text: text)
end

private
Expand Down
6 changes: 0 additions & 6 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
class User < ApplicationRecord
include CoPlan::UserModel

validates :email, presence: true, uniqueness: true
validates :name, presence: true
validates :role, presence: true, inclusion: { in: %w[member admin] }
Expand All @@ -9,10 +7,6 @@ def admin?
role == "admin"
end

def can_admin_coplan?
admin?
end

def email_domain
email.to_s.split("@").last&.downcase
end
Expand Down
14 changes: 13 additions & 1 deletion config/initializers/coplan.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
CoPlan.configure do |config|
config.user_class = "User"
config.authenticate = ->(request) {
user_id = request.session[:user_id]
return nil unless user_id

user = User.find_by(id: user_id)
return nil unless user

{
external_id: user.id,
name: user.name,
admin: user.admin?
}
}

config.ai_api_key = Rails.application.credentials.dig(:openai, :api_key) || ENV["OPENAI_API_KEY"]
config.ai_model = "gpt-4o"
Expand Down
53 changes: 53 additions & 0 deletions db/migrate/20260226200001_create_coplan_users_and_migrate.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
class CreateCoplanUsersAndMigrate < ActiveRecord::Migration[8.1]
def up
create_table :coplan_users, id: { type: :string, limit: 36 } do |t|
t.string :external_id, null: false
t.string :name, null: false
t.boolean :admin, default: false, null: false
t.json :metadata, default: {}
t.timestamps
end

add_index :coplan_users, :external_id, unique: true

# Migrate existing users — reuse the same id so FK columns stay valid
execute <<~SQL
INSERT INTO coplan_users (id, external_id, name, admin, metadata, created_at, updated_at)
SELECT id, id, name, (role = 'admin'), '{}', created_at, updated_at
FROM users
SQL

# Update foreign keys to point to coplan_users instead of users
remove_foreign_key :coplan_api_tokens, column: :user_id
remove_foreign_key :coplan_comment_threads, column: :created_by_user_id
remove_foreign_key :coplan_comment_threads, column: :resolved_by_user_id
remove_foreign_key :coplan_plan_collaborators, column: :user_id
remove_foreign_key :coplan_plan_collaborators, column: :added_by_user_id
remove_foreign_key :coplan_plans, column: :created_by_user_id

add_foreign_key :coplan_api_tokens, :coplan_users, column: :user_id
add_foreign_key :coplan_comment_threads, :coplan_users, column: :created_by_user_id
add_foreign_key :coplan_comment_threads, :coplan_users, column: :resolved_by_user_id
add_foreign_key :coplan_plan_collaborators, :coplan_users, column: :user_id
add_foreign_key :coplan_plan_collaborators, :coplan_users, column: :added_by_user_id
add_foreign_key :coplan_plans, :coplan_users, column: :created_by_user_id
end

def down
remove_foreign_key :coplan_api_tokens, column: :user_id
remove_foreign_key :coplan_comment_threads, column: :created_by_user_id
remove_foreign_key :coplan_comment_threads, column: :resolved_by_user_id
remove_foreign_key :coplan_plan_collaborators, column: :user_id
remove_foreign_key :coplan_plan_collaborators, column: :added_by_user_id
remove_foreign_key :coplan_plans, column: :created_by_user_id

add_foreign_key :coplan_api_tokens, :users, column: :user_id

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remap CoPlan user IDs before restoring user foreign keys

The down path re-adds FKs to users immediately, but after up runs, newly provisioned coplan_users no longer share IDs with host users (only external_id matches). If any post-migration data references those new coplan_users.id values (e.g., coplan_plans.created_by_user_id), add_foreign_key ... :users fails with FK violations and blocks rollback during an incident. The rollback needs a data remap from CoPlan IDs back to host user IDs (via external_id) or should be marked irreversible.

Useful? React with 👍 / 👎.

add_foreign_key :coplan_comment_threads, :users, column: :created_by_user_id
add_foreign_key :coplan_comment_threads, :users, column: :resolved_by_user_id
add_foreign_key :coplan_plan_collaborators, :users, column: :user_id
add_foreign_key :coplan_plan_collaborators, :users, column: :added_by_user_id
add_foreign_key :coplan_plans, :users, column: :created_by_user_id

drop_table :coplan_users
end
end
149 changes: 149 additions & 0 deletions docs/HOST_APP_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# CoPlan Host App Integration Guide

## Overview

CoPlan is a Rails engine that manages collaborative planning documents. It owns its own `CoPlan::User` model and handles authentication internally via a callback you configure.

## Setup

### 1. Mount the engine

```ruby
# config/routes.rb
mount CoPlan::Engine, at: "/coplan"
```

### 2. Install migrations

```bash
bin/rails coplan:install:migrations
bin/rails db:migrate
```

This creates the engine's tables (`coplan_users`, `coplan_plans`, etc.) in your database.

### 3. Configure authentication

Provide an `authenticate` callback that receives a Rack request and returns user identity attributes (or `nil` if unauthenticated):

```ruby
# config/initializers/coplan.rb
CoPlan.configure do |config|
config.authenticate = ->(request) {
# Example: session-based auth
user_id = request.session[:user_id]
return nil unless user_id

user = User.find_by(id: user_id)
return nil unless user

{
external_id: user.id.to_s, # required — unique ID from your auth system
name: user.name, # required — display name
admin: user.admin?, # optional — can manage CoPlan settings (default: false)
metadata: {} # optional — arbitrary data (default: {})
}
}

# Optional: AI provider configuration
config.ai_api_key = ENV["OPENAI_API_KEY"]
config.ai_model = "gpt-4o"
end
```

The callback is called on every CoPlan request. The engine automatically finds or creates a `CoPlan::User` from the returned attributes, keeping the name and admin flag in sync.

### Callback return values

| Key | Type | Required | Description |
|---------------|---------|----------|-------------|
| `external_id` | String | Yes | Unique identifier from your auth system |
| `name` | String | Yes | Display name |
| `admin` | Boolean | No | Can manage reviewers, settings (default: `false`) |
| `metadata` | Hash | No | Arbitrary data stored as JSON (default: `{}`) |

Return `nil` to indicate the user is not authenticated (the engine will respond with `401 Unauthorized`).

## Authentication examples

### Trogdor (Square internal)

```ruby
config.authenticate = ->(request) {
trogdor = Rails::Auth.credentials(request.env)["trogdor"]
return nil unless trogdor

{
external_id: trogdor.uid.to_s,
name: trogdor.username,
admin: trogdor.capabilities.include?("owners")
}
}
```

### Devise

```ruby
config.authenticate = ->(request) {
env = request.env
warden = env["warden"]
user = warden&.user
return nil unless user

{
external_id: user.id.to_s,
name: user.name,
admin: user.admin?
}
}
```

## CoPlan::User model

The engine manages a `coplan_users` table with these columns:

| Column | Type | Description |
|---------------|---------|-------------|
| `id` | String | UUIDv7 primary key (auto-assigned) |
| `external_id` | String | Unique ID from your auth system |
| `name` | String | Display name |
| `admin` | Boolean | Admin flag |
| `metadata` | JSON | Extensible data bag |

`CoPlan::User` is a normal ActiveRecord model. Host apps can reference it directly:

```ruby
class Notification < ApplicationRecord
belongs_to :user, class_name: "CoPlan::User"
end
```

## API tokens

CoPlan provides a REST API for programmatic access. Users create API tokens in the Settings UI. API requests authenticate via `Authorization: Bearer <token>` headers — no session or callback required.

## Layout and sign-out

CoPlan inherits from your `::ApplicationController` for layout and middleware. The engine's nav bar will render a "Sign out" link if your app defines a `sign_out_path` route helper.

## Configuration reference

```ruby
CoPlan.configure do |config|
# Required
config.authenticate = ->(request) { ... }

# AI provider (optional)
config.ai_base_url = "https://api.openai.com/v1" # default
config.ai_api_key = nil
config.ai_model = "gpt-4o" # default

# Error reporting (optional)
config.error_reporter = ->(exception, context) {
Rails.error.report(exception, context: context) # default
}

# Notifications (optional)
config.notification_handler = ->(event, payload) { ... }
end
```
46 changes: 45 additions & 1 deletion engine/app/controllers/coplan/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,54 @@ def self.controller_path
helper CoPlan::MarkdownHelper
helper CoPlan::CommentsHelper

# Skip host auth — CoPlan handles authentication internally via config.authenticate
skip_before_action :authenticate_user!, raise: false

before_action :authenticate_coplan_user!
before_action :set_coplan_current

helper_method :current_user, :signed_in?

class NotAuthorizedError < StandardError; end

rescue_from NotAuthorizedError do
head :not_found
end

private

def current_user
@current_coplan_user
end

def signed_in?
current_user.present?
end

def authenticate_coplan_user!
callback = CoPlan.configuration.authenticate
unless callback
raise "CoPlan.configure { |c| c.authenticate = ->(request) { ... } } is required"
end

attrs = callback.call(request)
unless attrs && attrs[:external_id].present?
head :unauthorized
return
end

external_id = attrs[:external_id].to_s
@current_coplan_user = CoPlan::User.find_or_initialize_by(external_id: external_id)
@current_coplan_user.assign_attributes(attrs.slice(:name, :admin, :metadata).compact)
if @current_coplan_user.new_record? || @current_coplan_user.changed?
@current_coplan_user.save!
end
rescue ActiveRecord::RecordNotUnique
@current_coplan_user = CoPlan::User.find_by!(external_id: external_id)
@current_coplan_user.assign_attributes(attrs.slice(:name, :admin, :metadata).compact)
@current_coplan_user.save! if @current_coplan_user.changed?
end

def set_coplan_current
CoPlan::Current.user = current_user
end
Expand All @@ -23,7 +67,7 @@ def authorize!(record, action)
policy_class = "CoPlan::#{record.class.name.demodulize}Policy".constantize
policy = policy_class.new(current_user, record)
unless policy.public_send(action)
raise ::ApplicationController::NotAuthorizedError
raise NotAuthorizedError
end
end
end
Expand Down
8 changes: 4 additions & 4 deletions engine/app/controllers/coplan/settings/tokens_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@ module CoPlan
module Settings
class TokensController < ApplicationController
def index
@api_tokens = current_user.coplan_api_tokens.order(created_at: :desc)
@api_tokens = current_user.api_tokens.order(created_at: :desc)
end

def create
@api_token, @raw_token = ApiToken.create_with_raw_token(user: current_user, name: params[:api_token][:name])
@api_tokens = current_user.coplan_api_tokens.order(created_at: :desc)
@api_tokens = current_user.api_tokens.order(created_at: :desc)
flash.now[:notice] = "Token created. Copy it now — it won't be shown again."
render :index
rescue ActiveRecord::RecordInvalid => e
@api_tokens = current_user.coplan_api_tokens.order(created_at: :desc)
@api_tokens = current_user.api_tokens.order(created_at: :desc)
flash.now[:alert] = e.message
render :index, status: :unprocessable_entity
end

def destroy
token = current_user.coplan_api_tokens.find(params[:id])
token = current_user.api_tokens.find(params[:id])
token.revoke!
redirect_to settings_tokens_path, notice: "Token revoked."
end
Expand Down
8 changes: 4 additions & 4 deletions engine/app/helpers/coplan/comments_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ module CommentsHelper
def comment_author_name(comment)
case comment.author_type
when "human"
CoPlan.user_class.find_by(id: comment.author_id)&.name || "Unknown"
CoPlan::User.find_by(id: comment.author_id)&.name || "Unknown"
when "local_agent"
user_name = CoPlan.user_class
.joins("INNER JOIN coplan_api_tokens ON coplan_api_tokens.user_id = users.id")
.where("coplan_api_tokens.id = ?", comment.author_id)
user_name = CoPlan::User
.joins(:api_tokens)
.where(coplan_api_tokens: { id: comment.author_id })
.pick(:name) || "Agent"
comment.agent_name.present? ? "#{user_name} (#{comment.agent_name})" : user_name
when "cloud_persona"
Expand Down
2 changes: 1 addition & 1 deletion engine/app/models/coplan/api_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module CoPlan
class ApiToken < ApplicationRecord
HOLDER_TYPE = "local_agent"

belongs_to :user, class_name: CoPlan.user_class_name
belongs_to :user, class_name: "CoPlan::User"

validates :name, presence: true
validates :token_digest, presence: true, uniqueness: true
Expand Down
4 changes: 2 additions & 2 deletions engine/app/models/coplan/comment_thread.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ class CommentThread < ApplicationRecord

belongs_to :plan
belongs_to :plan_version
belongs_to :created_by_user, class_name: CoPlan.user_class_name
belongs_to :resolved_by_user, class_name: CoPlan.user_class_name, optional: true
belongs_to :created_by_user, class_name: "CoPlan::User"
belongs_to :resolved_by_user, class_name: "CoPlan::User", optional: true
belongs_to :out_of_date_since_version, class_name: "PlanVersion", optional: true
belongs_to :addressed_in_plan_version, class_name: "PlanVersion", optional: true
has_many :comments, dependent: :destroy
Expand Down
2 changes: 1 addition & 1 deletion engine/app/models/coplan/plan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module CoPlan
class Plan < ApplicationRecord
STATUSES = %w[brainstorm considering developing live abandoned].freeze

belongs_to :created_by_user, class_name: CoPlan.user_class_name
belongs_to :created_by_user, class_name: "CoPlan::User"
belongs_to :current_plan_version, class_name: "PlanVersion", optional: true
has_many :plan_versions, -> { order(revision: :asc) }, dependent: :destroy
has_many :plan_collaborators, dependent: :destroy
Expand Down
Loading