agreement_engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +22 -0
  3. data/.github/workflows/release.yml +15 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +19 -0
  6. data/LICENSE +21 -0
  7. data/README.md +104 -0
  8. data/Rakefile +8 -0
  9. data/app/controllers/agreement_engine/admin/documents_controller.rb +71 -0
  10. data/app/controllers/agreement_engine/agreements_controller.rb +57 -0
  11. data/app/controllers/agreement_engine/documents_controller.rb +15 -0
  12. data/app/models/agreement_engine/document.rb +27 -0
  13. data/app/views/agreement_engine/admin/documents/_form.html.erb +44 -0
  14. data/app/views/agreement_engine/admin/documents/edit.html.erb +5 -0
  15. data/app/views/agreement_engine/admin/documents/index.html.erb +38 -0
  16. data/app/views/agreement_engine/admin/documents/new.html.erb +5 -0
  17. data/app/views/agreement_engine/admin/documents/show.html.erb +30 -0
  18. data/app/views/agreement_engine/agreements/show.html.erb +24 -0
  19. data/app/views/agreement_engine/documents/privacy.html.erb +16 -0
  20. data/app/views/agreement_engine/documents/terms.html.erb +16 -0
  21. data/app/views/layouts/agreement_engine/minimal.html.erb +11 -0
  22. data/config/locales/en.yml +11 -0
  23. data/config/routes.rb +10 -0
  24. data/db/migrate/20260208000001_create_fine_print_documents.rb +15 -0
  25. data/db/migrate/20260208000002_add_fine_print_to_users.rb +8 -0
  26. data/db/migrate/20260921200000_rename_fine_print_tables_to_agreement_engine.rb +5 -0
  27. data/lib/agreement_engine/agreement.rb +23 -0
  28. data/lib/agreement_engine/configuration.rb +36 -0
  29. data/lib/agreement_engine/enforceable.rb +22 -0
  30. data/lib/agreement_engine/engine.rb +13 -0
  31. data/lib/agreement_engine/signable.rb +23 -0
  32. data/lib/agreement_engine/version.rb +5 -0
  33. data/lib/agreement_engine.rb +21 -0
  34. data/lib/generators/agreement_engine/install_generator.rb +32 -0
  35. data/lib/generators/agreement_engine/templates/initializer.rb +18 -0
  36. data/lib/tasks/agreement_engine.rake +23 -0
  37. metadata +106 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 20b626073ba02a1bbcdb3512d51adae2aaffefd73eb31f1f1dc9a985969d89f0
4
+ data.tar.gz: 33445687584c472677c84c26d3e8e3dc151e911c8b3f375b7a15c7fe6f9854a4
5
+ SHA512:
6
+ metadata.gz: 33bde405dbcf745dcfced2ddf15280b9491ddcc22dde298a14b8a6ebdbe6f7721fa00413f1e01b2b81fd16144590d6462a427c3bf61b962bab03ba8d89d13225
7
+ data.tar.gz: 8afb74c402eda31255db66d590b64a52661ed3b734bddfa42e6da518216f95bf9aaf29916c0f1f55b70ca92afee32bd56581cdb47b6539c2ca25428ca16d66c1
@@ -0,0 +1,22 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [ main ]
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout code
14
+ uses: actions/checkout@v6
15
+
16
+ - name: Set up Ruby
17
+ uses: ruby/setup-ruby@v1
18
+ with:
19
+ bundler-cache: true
20
+
21
+ - name: Run tests
22
+ run: bundle exec rake test
@@ -0,0 +1,15 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ release:
10
+ permissions:
11
+ contents: write
12
+ id-token: write
13
+ issues: write
14
+ actions: read
15
+ uses: DYB-Development/symphony/.github/workflows/gem-release.yml@main
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.6
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.0] - 2026-02-12
6
+
7
+ ### Added
8
+
9
+ - Rails engine with isolated namespace
10
+ - `AgreementEngine::Document` model with versioned legal document management
11
+ - `Signable` concern for user models to track agreement acceptance
12
+ - `Enforceable` concern for controllers to require agreement acceptance
13
+ - `AgreementsController` for user-facing acceptance flow
14
+ - `DocumentsController` with public terms and privacy pages
15
+ - `Admin::DocumentsController` with full CRUD and auth protection
16
+ - Engine migrations for documents table and user tracking columns
17
+ - Seed rake task (`agreement_engine:seed`) for initial legal documents
18
+ - Host app route helpers exposed to engine views
19
+ - GitHub Actions CI workflow
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Tyler Schneider
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # AgreementEngine
2
+
3
+ Versioned legal document management for Rails. Track and enforce user acceptance of Terms of Service, Privacy Policy, and other legal agreements with versioned documents and audit trails.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "agreement_engine"
11
+ ```
12
+
13
+ Then run:
14
+
15
+ ```bash
16
+ bundle install
17
+ rails agreement_engine:install:migrations
18
+ rails db:migrate
19
+ ```
20
+
21
+ ## Setup
22
+
23
+ ### 1. Configure agreements
24
+
25
+ Create `config/initializers/agreement_engine.rb`:
26
+
27
+ ```ruby
28
+ AgreementEngine.configure do |config|
29
+ config.agreements = [
30
+ AgreementEngine::Agreement.new(
31
+ id: :terms_of_service,
32
+ title: "Terms Of Service",
33
+ version_column: :accepted_terms_of_service_version_id,
34
+ document_type: :terms_of_service,
35
+ prompt_when_updated: true
36
+ ),
37
+ AgreementEngine::Agreement.new(
38
+ id: :privacy_policy,
39
+ title: "Privacy Policy",
40
+ version_column: :accepted_privacy_policy_version_id,
41
+ document_type: :privacy_policy,
42
+ prompt_when_updated: true
43
+ )
44
+ ]
45
+ end
46
+ ```
47
+
48
+ ### 2. Include concerns
49
+
50
+ ```ruby
51
+ # app/models/user.rb
52
+ class User < ApplicationRecord
53
+ include AgreementEngine::Signable
54
+ end
55
+
56
+ # app/controllers/application_controller.rb
57
+ class ApplicationController < ActionController::Base
58
+ include AgreementEngine::Enforceable
59
+ end
60
+ ```
61
+
62
+ ### 3. Mount routes
63
+
64
+ ```ruby
65
+ # config/routes.rb
66
+ mount AgreementEngine::Engine, at: "/"
67
+ ```
68
+
69
+ ### 4. Seed initial documents
70
+
71
+ ```bash
72
+ rails agreement_engine:seed
73
+ ```
74
+
75
+ ## Auth Configuration
76
+
77
+ AgreementEngine defaults to Devise conventions. Override for other auth systems:
78
+
79
+ ```ruby
80
+ AgreementEngine.configure do |config|
81
+ config.current_user_method = ->(c) { c.current_user }
82
+ config.signed_in_method = ->(c) { c.user_signed_in? }
83
+ config.auth_controller_method = ->(c) { c.devise_controller? }
84
+ config.sign_out_method = ->(c) { c.sign_out(c.current_user) }
85
+ end
86
+ ```
87
+
88
+ ## Routes
89
+
90
+ The engine provides:
91
+
92
+ - `GET /agreements/:id` - Show agreement for acceptance
93
+ - `PATCH /agreements/:id` - Accept agreement
94
+ - `DELETE /agreements/:id` - Decline agreement (signs user out)
95
+ - `GET /terms` - Public terms of service page
96
+ - `GET /privacy` - Public privacy policy page
97
+ - `GET /admin/documents` - Admin CRUD for managing document versions
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ bundle install
103
+ bundle exec rake test
104
+ ```
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgreementEngine
4
+ module Admin
5
+ class DocumentsController < ::ApplicationController
6
+ skip_before_action :require_accepted_agreements!, raise: false
7
+ before_action :authenticate_admin!
8
+ before_action :set_document, only: [:show, :edit, :update, :destroy]
9
+
10
+ def index
11
+ @documents = AgreementEngine::Document.order(created_at: :desc)
12
+ @documents = apply_scope(@documents)
13
+ end
14
+
15
+ def show
16
+ end
17
+
18
+ def new
19
+ @document = AgreementEngine::Document.new
20
+ end
21
+
22
+ def create
23
+ @document = AgreementEngine::Document.new(document_params)
24
+
25
+ if @document.save
26
+ redirect_to agreement_engine.admin_document_path(@document), notice: "Document was successfully created."
27
+ else
28
+ render :new, status: :unprocessable_entity
29
+ end
30
+ end
31
+
32
+ def edit
33
+ end
34
+
35
+ def update
36
+ if @document.update(document_params)
37
+ redirect_to agreement_engine.admin_document_path(@document), notice: "Document was successfully updated."
38
+ else
39
+ render :edit, status: :unprocessable_entity
40
+ end
41
+ end
42
+
43
+ def destroy
44
+ @document.destroy
45
+ redirect_to agreement_engine.admin_documents_path, notice: "Document was successfully deleted.", status: :see_other
46
+ end
47
+
48
+ private
49
+
50
+ def authenticate_admin!
51
+ AgreementEngine.config.authenticate_admin!(self)
52
+ end
53
+
54
+ def set_document
55
+ @document = AgreementEngine::Document.find(params[:id])
56
+ end
57
+
58
+ def document_params
59
+ params.require(:document).permit(:document_type, :version, :summary, :effective_at, :content)
60
+ end
61
+
62
+ def apply_scope(documents)
63
+ case params[:scope]
64
+ when "published" then documents.published
65
+ when "draft" then documents.draft
66
+ else documents
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgreementEngine
4
+ class AgreementsController < ::ApplicationController
5
+ skip_before_action :require_accepted_agreements!, raise: false
6
+ before_action :agreement_engine_authenticate_user!
7
+ before_action :set_agreement
8
+ before_action :set_version
9
+
10
+ layout "agreement_engine/minimal"
11
+
12
+ def show
13
+ end
14
+
15
+ def update
16
+ if @version
17
+ agreement_engine_current_user.accept_version!(@agreement, @version)
18
+ end
19
+ redirect_to after_accepted_path, status: :see_other
20
+ end
21
+
22
+ def destroy
23
+ AgreementEngine.config.sign_out(self)
24
+ redirect_to main_app.root_path, status: :see_other, alert: t(".declined", agreement: @agreement.title)
25
+ end
26
+
27
+ private
28
+
29
+ def agreement_engine_current_user
30
+ AgreementEngine.config.current_user(self)
31
+ end
32
+
33
+ def agreement_engine_authenticate_user!
34
+ unless AgreementEngine.config.signed_in?(self)
35
+ redirect_to main_app.root_path
36
+ end
37
+ end
38
+
39
+ def set_agreement
40
+ @agreement = AgreementEngine.config.agreements.find { |a| a.id.to_s == params[:id] }
41
+
42
+ if @agreement.nil?
43
+ redirect_to main_app.root_path
44
+ elsif @agreement.accepted_by?(agreement_engine_current_user)
45
+ redirect_to after_accepted_path
46
+ end
47
+ end
48
+
49
+ def set_version
50
+ @version = @agreement&.current_version
51
+ end
52
+
53
+ def after_accepted_path
54
+ stored_location_for(:user) || main_app.root_path
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgreementEngine
4
+ class DocumentsController < ::ApplicationController
5
+ def terms
6
+ @agreement = AgreementEngine.config.agreements.find { |a| a.id == :terms_of_service }
7
+ @version = AgreementEngine::Document.current(:terms_of_service)
8
+ end
9
+
10
+ def privacy
11
+ @agreement = AgreementEngine.config.agreements.find { |a| a.id == :privacy_policy }
12
+ @version = AgreementEngine::Document.current(:privacy_policy)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgreementEngine
4
+ class Document < ActiveRecord::Base
5
+ self.table_name = "agreement_engine_documents"
6
+
7
+ has_rich_text :content
8
+
9
+ validates :document_type, presence: true
10
+ validates :version, presence: true, uniqueness: {scope: :document_type}
11
+ validates :content, presence: true
12
+
13
+ scope :published, -> { where("effective_at <= ?", Time.current) }
14
+ scope :draft, -> { where(effective_at: nil) }
15
+
16
+ def self.current(document_type)
17
+ where(document_type: document_type)
18
+ .published
19
+ .order(effective_at: :desc)
20
+ .first
21
+ end
22
+
23
+ def published?
24
+ effective_at.present? && effective_at <= Time.current
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,44 @@
1
+ <%= form_with(model: document, url: url, class: "space-y-4") do |form| %>
2
+ <% if document.errors.any? %>
3
+ <div class="bg-red-50 border border-red-200 rounded p-4">
4
+ <h2 class="text-red-800 font-medium"><%= pluralize(document.errors.count, "error") %> prohibited this document from being saved:</h2>
5
+ <ul class="mt-2 list-disc list-inside text-red-700">
6
+ <% document.errors.full_messages.each do |message| %>
7
+ <li><%= message %></li>
8
+ <% end %>
9
+ </ul>
10
+ </div>
11
+ <% end %>
12
+
13
+ <div>
14
+ <%= form.label :document_type, class: "block text-sm font-medium text-gray-700" %>
15
+ <%= form.select :document_type,
16
+ AgreementEngine.config.agreements.map { |a| [a.title, a.document_type] },
17
+ {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm" %>
18
+ </div>
19
+
20
+ <div>
21
+ <%= form.label :version, class: "block text-sm font-medium text-gray-700" %>
22
+ <%= form.text_field :version, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm" %>
23
+ </div>
24
+
25
+ <div>
26
+ <%= form.label :summary, class: "block text-sm font-medium text-gray-700" %>
27
+ <%= form.text_area :summary, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm" %>
28
+ </div>
29
+
30
+ <div>
31
+ <%= form.label :effective_at, class: "block text-sm font-medium text-gray-700" %>
32
+ <%= form.datetime_local_field :effective_at, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm" %>
33
+ <p class="mt-1 text-sm text-gray-500">Leave blank to save as draft.</p>
34
+ </div>
35
+
36
+ <div>
37
+ <%= form.label :content, class: "block text-sm font-medium text-gray-700" %>
38
+ <%= form.rich_text_area :content, class: "mt-1 block w-full" %>
39
+ </div>
40
+
41
+ <div>
42
+ <%= form.submit class: "btn btn-primary" %>
43
+ </div>
44
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <div class="mx-auto max-w-4xl px-4 py-8">
2
+ <h1 class="text-2xl font-bold mb-6">Edit Document</h1>
3
+
4
+ <%= render "form", document: @document, url: agreement_engine.admin_document_path(@document) %>
5
+ </div>
@@ -0,0 +1,38 @@
1
+ <div class="mx-auto max-w-6xl px-4 py-8">
2
+ <div class="flex items-center justify-between mb-6">
3
+ <h1 class="text-2xl font-bold">Legal Documents</h1>
4
+ <%= link_to "New Document", agreement_engine.new_admin_document_path, class: "btn btn-primary" %>
5
+ </div>
6
+
7
+ <div class="mb-4 flex gap-2">
8
+ <%= link_to "All", agreement_engine.admin_documents_path, class: "px-3 py-1 rounded #{params[:scope].blank? ? 'bg-gray-900 text-white' : 'bg-gray-100'}" %>
9
+ <%= link_to "Published", agreement_engine.admin_documents_path(scope: "published"), class: "px-3 py-1 rounded #{params[:scope] == 'published' ? 'bg-gray-900 text-white' : 'bg-gray-100'}" %>
10
+ <%= link_to "Draft", agreement_engine.admin_documents_path(scope: "draft"), class: "px-3 py-1 rounded #{params[:scope] == 'draft' ? 'bg-gray-900 text-white' : 'bg-gray-100'}" %>
11
+ </div>
12
+
13
+ <table class="min-w-full divide-y divide-gray-200">
14
+ <thead>
15
+ <tr>
16
+ <th class="px-4 py-2 text-left text-sm font-medium text-gray-500">Type</th>
17
+ <th class="px-4 py-2 text-left text-sm font-medium text-gray-500">Version</th>
18
+ <th class="px-4 py-2 text-left text-sm font-medium text-gray-500">Status</th>
19
+ <th class="px-4 py-2 text-left text-sm font-medium text-gray-500">Effective</th>
20
+ <th class="px-4 py-2 text-left text-sm font-medium text-gray-500">Actions</th>
21
+ </tr>
22
+ </thead>
23
+ <tbody class="divide-y divide-gray-200">
24
+ <% @documents.each do |document| %>
25
+ <tr>
26
+ <td class="px-4 py-2"><%= document.document_type.titleize %></td>
27
+ <td class="px-4 py-2"><%= document.version %></td>
28
+ <td class="px-4 py-2"><%= document.published? ? "Published" : "Draft" %></td>
29
+ <td class="px-4 py-2"><%= document.effective_at&.strftime("%B %d, %Y") || "—" %></td>
30
+ <td class="px-4 py-2 flex gap-2">
31
+ <%= link_to "View", agreement_engine.admin_document_path(document) %>
32
+ <%= link_to "Edit", agreement_engine.edit_admin_document_path(document) %>
33
+ </td>
34
+ </tr>
35
+ <% end %>
36
+ </tbody>
37
+ </table>
38
+ </div>
@@ -0,0 +1,5 @@
1
+ <div class="mx-auto max-w-4xl px-4 py-8">
2
+ <h1 class="text-2xl font-bold mb-6">New Document</h1>
3
+
4
+ <%= render "form", document: @document, url: agreement_engine.admin_documents_path %>
5
+ </div>
@@ -0,0 +1,30 @@
1
+ <div class="mx-auto max-w-4xl px-4 py-8">
2
+ <div class="flex items-center justify-between mb-6">
3
+ <h1 class="text-2xl font-bold"><%= @document.document_type.titleize %> v<%= @document.version %></h1>
4
+ <div class="flex gap-2">
5
+ <%= link_to "Edit", agreement_engine.edit_admin_document_path(@document), class: "btn btn-secondary" %>
6
+ <%= link_to "Back", agreement_engine.admin_documents_path, class: "btn btn-secondary" %>
7
+ </div>
8
+ </div>
9
+
10
+ <dl class="grid grid-cols-2 gap-4 mb-6">
11
+ <div>
12
+ <dt class="text-sm font-medium text-gray-500">Status</dt>
13
+ <dd><%= @document.published? ? "Published" : "Draft" %></dd>
14
+ </div>
15
+ <div>
16
+ <dt class="text-sm font-medium text-gray-500">Effective Date</dt>
17
+ <dd><%= @document.effective_at&.strftime("%B %d, %Y") || "Not set" %></dd>
18
+ </div>
19
+ <% if @document.summary.present? %>
20
+ <div class="col-span-2">
21
+ <dt class="text-sm font-medium text-gray-500">Summary</dt>
22
+ <dd><%= @document.summary %></dd>
23
+ </div>
24
+ <% end %>
25
+ </dl>
26
+
27
+ <article class="prose prose-gray dark:prose-invert max-w-none">
28
+ <%= @document.content %>
29
+ </article>
30
+ </div>
@@ -0,0 +1,24 @@
1
+ <div class="max-w-4xl mx-auto p-4 my-8 lg:my-24">
2
+ <h1 class="mb-4"><%= t(".title", agreement: @agreement.title) %></h1>
3
+
4
+ <% if @version %>
5
+ <%= tag.p t(".last_updated", date: l(@version.effective_at.to_date, format: :long)), class: "mb-4 text-gray-700 leading-normal italic" %>
6
+
7
+ <article class="prose prose-gray dark:prose-invert max-w-none">
8
+ <%= @version.content %>
9
+ </article>
10
+ <% else %>
11
+ <p class="text-gray-500">No version has been published yet.</p>
12
+ <% end %>
13
+
14
+ <div class="mt-8 sm:mt-16">
15
+ <div class="text-center">
16
+ <%= t(".description", agreement: @agreement.title) %>
17
+ </div>
18
+
19
+ <div class="mt-4 flex items-center justify-center gap-2">
20
+ <%= button_to t(".decline"), agreement_engine.agreement_path(@agreement), method: :delete, class: "btn btn-secondary btn-large" %>
21
+ <%= button_to t(".accept"), agreement_engine.agreement_path(@agreement), method: :patch, class: "btn btn-primary btn-large" %>
22
+ </div>
23
+ </div>
24
+ </div>
@@ -0,0 +1,16 @@
1
+ <% content_for :title, "Privacy Policy" %>
2
+
3
+ <div class="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
4
+ <article class="prose prose-gray dark:prose-invert max-w-none">
5
+ <h1>Privacy Policy</h1>
6
+
7
+ <% if @version %>
8
+ <p class="text-sm text-gray-500 dark:text-gray-400">
9
+ Version <%= @version.version %> - Effective <%= @version.effective_at.strftime("%B %d, %Y") %>
10
+ </p>
11
+ <%= @version.content %>
12
+ <% else %>
13
+ <p class="text-gray-500">No privacy policy has been published yet.</p>
14
+ <% end %>
15
+ </article>
16
+ </div>
@@ -0,0 +1,16 @@
1
+ <% content_for :title, "Terms of Service" %>
2
+
3
+ <div class="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
4
+ <article class="prose prose-gray dark:prose-invert max-w-none">
5
+ <h1>Terms of Service</h1>
6
+
7
+ <% if @version %>
8
+ <p class="text-sm text-gray-500 dark:text-gray-400">
9
+ Version <%= @version.version %> - Effective <%= @version.effective_at.strftime("%B %d, %Y") %>
10
+ </p>
11
+ <%= @version.content %>
12
+ <% else %>
13
+ <p class="text-gray-500">No terms of service have been published yet.</p>
14
+ <% end %>
15
+ </article>
16
+ </div>
@@ -0,0 +1,11 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= yield :title %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ </head>
8
+ <body>
9
+ <%= yield %>
10
+ </body>
11
+ </html>
@@ -0,0 +1,11 @@
1
+ en:
2
+ agreement_engine:
3
+ agreements:
4
+ show:
5
+ title: "%{agreement} Changed"
6
+ last_updated: "Last updated %{date}"
7
+ description: "Before you can proceed, you must read and accept the new %{agreement}."
8
+ accept: "I Accept"
9
+ decline: "I Decline"
10
+ destroy:
11
+ declined: "To continue using the service, please sign in and accept the %{agreement}."
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ AgreementEngine::Engine.routes.draw do
2
+ resources :agreements, only: [:show, :update, :destroy]
3
+
4
+ get "terms", to: "documents#terms", as: :terms
5
+ get "privacy", to: "documents#privacy", as: :privacy
6
+
7
+ namespace :admin do
8
+ resources :documents
9
+ end
10
+ end
@@ -0,0 +1,15 @@
1
+ class CreateFinePrintDocuments < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :fine_print_documents do |t|
4
+ t.string :document_type, null: false
5
+ t.string :version, null: false
6
+ t.datetime :effective_at
7
+ t.text :summary
8
+
9
+ t.timestamps
10
+ end
11
+
12
+ add_index :fine_print_documents, [:document_type, :version], unique: true
13
+ add_index :fine_print_documents, [:document_type, :effective_at]
14
+ end
15
+ end
@@ -0,0 +1,8 @@
1
+ class AddFinePrintToUsers < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :users, :accepted_terms_of_service_version_id, :bigint
4
+ add_column :users, :accepted_privacy_policy_version_id, :bigint
5
+ add_foreign_key :users, :fine_print_documents, column: :accepted_terms_of_service_version_id
6
+ add_foreign_key :users, :fine_print_documents, column: :accepted_privacy_policy_version_id
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ class RenameFinePrintTablesToAgreementEngine < ActiveRecord::Migration[8.0]
2
+ def change
3
+ rename_table :fine_print_documents, :agreement_engine_documents
4
+ end
5
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgreementEngine
4
+ Agreement = Data.define(:id, :title, :version_column, :document_type, :prompt_when_updated) do
5
+ def current_version
6
+ AgreementEngine::Document.current(document_type)
7
+ end
8
+
9
+ def accepted_by?(user)
10
+ current = current_version
11
+ return true unless current
12
+ user.public_send(version_column) == current.id
13
+ end
14
+
15
+ def not_accepted_by?(user)
16
+ !accepted_by?(user)
17
+ end
18
+
19
+ def to_param = id
20
+
21
+ def to_partial_path = "agreement_engine/agreements/#{id}"
22
+ end
23
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgreementEngine
4
+ class Configuration
5
+ attr_accessor :agreements
6
+ attr_accessor :current_user_method
7
+ attr_accessor :signed_in_method
8
+ attr_accessor :auth_controller_method
9
+ attr_accessor :sign_out_method
10
+ attr_accessor :admin_auth_method
11
+
12
+ def initialize
13
+ @agreements = []
14
+
15
+ @current_user_method = ->(c) { c.current_user }
16
+ @signed_in_method = ->(c) { c.user_signed_in? }
17
+ @auth_controller_method = ->(c) { c.respond_to?(:devise_controller?) && c.devise_controller? }
18
+ @sign_out_method = ->(c) {
19
+ if defined?(Devise) && Devise.sign_out_all_scopes
20
+ c.sign_out
21
+ else
22
+ c.sign_out(c.current_user)
23
+ end
24
+ }
25
+ @admin_auth_method = ->(c) {
26
+ c.redirect_to("/", alert: "Not authorized") unless signed_in?(c)
27
+ }
28
+ end
29
+
30
+ def current_user(controller) = current_user_method.call(controller)
31
+ def signed_in?(controller) = signed_in_method.call(controller)
32
+ def auth_controller?(controller) = auth_controller_method.call(controller)
33
+ def sign_out(controller) = sign_out_method.call(controller)
34
+ def authenticate_admin!(controller) = admin_auth_method.call(controller)
35
+ end
36
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgreementEngine
4
+ module Enforceable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ before_action :require_accepted_agreements!,
9
+ if: -> { request.get? && AgreementEngine.config.signed_in?(self) && !AgreementEngine.config.auth_controller?(self) }
10
+ end
11
+
12
+ def require_accepted_agreements!
13
+ AgreementEngine.config.agreements.select(&:prompt_when_updated).each do |agreement|
14
+ if agreement.not_accepted_by?(AgreementEngine.config.current_user(self))
15
+ store_location_for(:user, request.fullpath) unless request.fullpath.start_with?("/agreements/")
16
+ redirect_to agreement_engine.agreement_path(agreement)
17
+ break
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgreementEngine
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace AgreementEngine
6
+
7
+ initializer "agreement_engine.url_helpers" do
8
+ ActiveSupport.on_load(:action_controller) do
9
+ helper Rails.application.routes.url_helpers
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgreementEngine
4
+ module Signable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ AgreementEngine.config.agreements.each do |agreement|
9
+ belongs_to :"accepted_#{agreement.document_type}_version",
10
+ class_name: "AgreementEngine::Document",
11
+ optional: true
12
+ end
13
+ end
14
+
15
+ def needs_acceptance?(agreement)
16
+ agreement.not_accepted_by?(self)
17
+ end
18
+
19
+ def accept_version!(agreement, version)
20
+ update!(agreement.version_column => version.id)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgreementEngine
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "agreement_engine/version"
4
+ require_relative "agreement_engine/configuration"
5
+ require_relative "agreement_engine/agreement"
6
+
7
+ module AgreementEngine
8
+ class << self
9
+ def config
10
+ @config ||= Configuration.new
11
+ end
12
+
13
+ def configure
14
+ yield config
15
+ end
16
+ end
17
+ end
18
+
19
+ require_relative "agreement_engine/engine"
20
+ require_relative "agreement_engine/signable"
21
+ require_relative "agreement_engine/enforceable"
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module AgreementEngine
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Installs AgreementEngine: adds route, copies initializer and migrations."
14
+
15
+ def mount_engine
16
+ route 'mount AgreementEngine::Engine, at: "/legal"'
17
+ end
18
+
19
+ def copy_initializer
20
+ template "initializer.rb", "config/initializers/agreement_engine.rb"
21
+ end
22
+
23
+ def copy_migrations
24
+ migrations_dir = AgreementEngine::Engine.root.join("db/migrate")
25
+ Dir[migrations_dir.join("*.rb")].sort.each do |migration|
26
+ filename = File.basename(migration).sub(/^\d+_/, "")
27
+ migration_template migration, "db/migrate/#{filename}"
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,18 @@
1
+ AgreementEngine.configure do |config|
2
+ # Lambda called to get the current user (default: controller.current_user)
3
+ # config.current_user_method = ->(controller) { controller.current_user }
4
+
5
+ # Lambda called to check if a user is signed in (default: controller.user_signed_in?)
6
+ # config.signed_in_method = ->(controller) { controller.user_signed_in? }
7
+
8
+ # Lambda called to authorize admin access (default: redirects if not signed in)
9
+ # config.admin_auth_method = ->(controller) {
10
+ # controller.redirect_to("/", alert: "Not authorized") unless config.signed_in?(controller)
11
+ # }
12
+
13
+ # Define your legal agreements:
14
+ # config.agreements = [
15
+ # AgreementEngine::Agreement.new(:terms, title: "Terms of Service", required: true),
16
+ # AgreementEngine::Agreement.new(:privacy, title: "Privacy Policy", required: true),
17
+ # ]
18
+ end
@@ -0,0 +1,23 @@
1
+ namespace :agreement_engine do
2
+ desc "Seed initial legal document versions for each configured agreement"
3
+ task seed: :environment do
4
+ puts "Seeding legal documents..."
5
+
6
+ AgreementEngine.config.agreements.each do |agreement|
7
+ if AgreementEngine::Document.where(document_type: agreement.document_type).exists?
8
+ puts " #{agreement.title} v1.0 already exists, skipping"
9
+ else
10
+ AgreementEngine::Document.create!(
11
+ document_type: agreement.document_type,
12
+ version: "1.0",
13
+ summary: "Initial #{agreement.title.downcase}",
14
+ content: "<h2>#{agreement.title}</h2><p>This is a placeholder. Please update with your actual #{agreement.title.downcase}.</p>",
15
+ effective_at: Time.current
16
+ )
17
+ puts " Created #{agreement.title} v1.0"
18
+ end
19
+ end
20
+
21
+ puts "Done!"
22
+ end
23
+ end
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: agreement_engine
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - tylercschneider
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "<"
17
+ - !ruby/object:Gem::Version
18
+ version: '3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "<"
24
+ - !ruby/object:Gem::Version
25
+ version: '3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ description: Track and enforce user acceptance of Terms of Service, Privacy Policy,
41
+ and other legal agreements with versioned documents and audit trails.
42
+ email:
43
+ - tylercschneider@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".github/workflows/ci.yml"
49
+ - ".github/workflows/release.yml"
50
+ - ".ruby-version"
51
+ - CHANGELOG.md
52
+ - LICENSE
53
+ - README.md
54
+ - Rakefile
55
+ - app/controllers/agreement_engine/admin/documents_controller.rb
56
+ - app/controllers/agreement_engine/agreements_controller.rb
57
+ - app/controllers/agreement_engine/documents_controller.rb
58
+ - app/models/agreement_engine/document.rb
59
+ - app/views/agreement_engine/admin/documents/_form.html.erb
60
+ - app/views/agreement_engine/admin/documents/edit.html.erb
61
+ - app/views/agreement_engine/admin/documents/index.html.erb
62
+ - app/views/agreement_engine/admin/documents/new.html.erb
63
+ - app/views/agreement_engine/admin/documents/show.html.erb
64
+ - app/views/agreement_engine/agreements/show.html.erb
65
+ - app/views/agreement_engine/documents/privacy.html.erb
66
+ - app/views/agreement_engine/documents/terms.html.erb
67
+ - app/views/layouts/agreement_engine/minimal.html.erb
68
+ - config/locales/en.yml
69
+ - config/routes.rb
70
+ - db/migrate/20260208000001_create_fine_print_documents.rb
71
+ - db/migrate/20260208000002_add_fine_print_to_users.rb
72
+ - db/migrate/20260921200000_rename_fine_print_tables_to_agreement_engine.rb
73
+ - lib/agreement_engine.rb
74
+ - lib/agreement_engine/agreement.rb
75
+ - lib/agreement_engine/configuration.rb
76
+ - lib/agreement_engine/enforceable.rb
77
+ - lib/agreement_engine/engine.rb
78
+ - lib/agreement_engine/signable.rb
79
+ - lib/agreement_engine/version.rb
80
+ - lib/generators/agreement_engine/install_generator.rb
81
+ - lib/generators/agreement_engine/templates/initializer.rb
82
+ - lib/tasks/agreement_engine.rake
83
+ homepage: https://github.com/DYB-Development/agreement_engine
84
+ licenses:
85
+ - MIT
86
+ metadata:
87
+ homepage_uri: https://github.com/DYB-Development/agreement_engine
88
+ source_code_uri: https://github.com/DYB-Development/agreement_engine
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: 3.2.0
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ requirements: []
103
+ rubygems_version: 4.0.20
104
+ specification_version: 4
105
+ summary: Versioned legal document management for Rails
106
+ test_files: []