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.
# Gemfile
gem "coplan-engine", require: "coplan"Then run bundle install.
# config/routes.rb
mount CoPlan::Engine, at: "/coplan"You can also mount at the root if CoPlan is the primary purpose of your app. When doing so, add as: "coplan" so the engine's route helpers resolve correctly:
# config/routes.rb
mount CoPlan::Engine, at: "/", as: "coplan"bin/rails coplan:install:migrations
bin/rails db:migrateThis creates the engine's tables (coplan_users, coplan_plans, etc.) in your database.
Note: The engine auto-appends its migration paths at boot, so you can skip
coplan:install:migrationsif you prefer —db:migratewill pick them up automatically. Useinstall:migrationsif you want local copies you can inspect or modify.
bin/rails coplan:seedCoPlan needs a built-in General plan type to exist. Replaying every
migration creates it, but databases initialized from a checked-in schema
(db:schema:load, db:prepare, db:setup) skip data migrations — the
tables exist and the migrations are marked applied, but the row is missing
and API plan creation with plan_type returns 422.
coplan:seed is idempotent and never overwrites plan types you've
customized, so run it after any of the setup paths above. Alternatively,
call CoPlan::Engine.load_seed from your own db/seeds.rb so db:setup
and db:seed cover it automatically.
Provide an authenticate callback that receives a Rack request and returns user identity attributes (or nil if unauthenticated):
# 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"
endThe 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.
| 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).
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?
}
}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:
class Notification < ApplicationRecord
belongs_to :user, class_name: "CoPlan::User"
endPlans support file attachments (images, PDFs, text/CSV/JSON, ZIP — max 25 MB per file), built on ActiveStorage.
The engine ships the ActiveStorage table migrations (active_storage_blobs, active_storage_attachments, active_storage_variant_records) alongside its own migrations, so they're installed the same way:
bin/rails co_plan:install:migrations
bin/rails db:migrateThe engine's copy differs from the stock Rails install migration in one way: active_storage_attachments.record_id is a string(36) column so it can hold CoPlan's UUID primary keys. The migration is guarded with table_exists? checks, so it's a no-op if your app already has ActiveStorage installed.
Warning: If your app already has ActiveStorage with a bigint
record_id, CoPlan attachments won't work — the polymorphicrecord_idcolumn can't store CoPlan's string UUIDs. You'll need to widen the column tostring(36)(existing integer IDs will store fine as strings, but verify any raw-SQL queries you have against that table).
Configure a storage service per environment (this is standard Rails — see the ActiveStorage guide):
# config/storage.yml
local:
service: Disk
root: <%= Rails.root.join("storage") %>
amazon:
service: S3
access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
region: us-east-1
bucket: your-bucket-<%= Rails.env %># config/environments/production.rb
config.active_storage.service = :amazon # or :local for single-node Disk storageThe local Disk service is fine for development and single-node deployments (make sure storage/ is on a persistent volume). Use S3 (add gem "aws-sdk-s3") or another cloud service for multi-node production deployments.
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.
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.
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) { ... }
# Analytics (optional)
#
# Fires for every event the engine instruments (page_view, plan_created,
# plan_published, comment_created, thread_resolved, ...). Called inline on
# the request thread; if your handler is slow, enqueue a job from inside it.
# Exceptions raised by the handler are swallowed and reported via
# `error_reporter`, so a broken sink will never break a user request.
#
# Payload always contains: :event, :timestamp, :user_id, :properties (Hash).
config.track_event = ->(event, payload) {
AnalyticsEvent.create!(name: event, payload: payload)
}
end