overule 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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +302 -0
  4. data/app/assets/javascripts/overule/builder.js +268 -0
  5. data/app/controllers/overule/activities_controller.rb +10 -0
  6. data/app/controllers/overule/application_controller.rb +35 -0
  7. data/app/controllers/overule/rule_versions_controller.rb +24 -0
  8. data/app/controllers/overule/rules_controller.rb +60 -0
  9. data/app/models/concerns/overule/rule_activity_behavior.rb +24 -0
  10. data/app/models/concerns/overule/rule_behavior.rb +106 -0
  11. data/app/models/concerns/overule/rule_version_behavior.rb +15 -0
  12. data/app/models/overule/current.rb +7 -0
  13. data/app/models/overule/rule.rb +48 -0
  14. data/app/models/overule/rule_activity.rb +40 -0
  15. data/app/models/overule/rule_version.rb +42 -0
  16. data/app/views/layouts/overule/application.html.erb +39 -0
  17. data/app/views/overule/activities/_activity.html.erb +56 -0
  18. data/app/views/overule/activities/index.html.erb +30 -0
  19. data/app/views/overule/rule_versions/index.html.erb +44 -0
  20. data/app/views/overule/rule_versions/show.html.erb +55 -0
  21. data/app/views/overule/rules/_form.html.erb +95 -0
  22. data/app/views/overule/rules/_group.html.erb +106 -0
  23. data/app/views/overule/rules/_static_node.html.erb +79 -0
  24. data/app/views/overule/rules/edit.html.erb +2 -0
  25. data/app/views/overule/rules/index.html.erb +45 -0
  26. data/app/views/overule/rules/new.html.erb +2 -0
  27. data/app/views/overule/rules/show.html.erb +54 -0
  28. data/config/routes.rb +8 -0
  29. data/lib/generators/overule/install/USAGE +25 -0
  30. data/lib/generators/overule/install/install_generator.rb +64 -0
  31. data/lib/generators/overule/install/templates/add_rule_version_to_overule_rule_activities.rb.tt +21 -0
  32. data/lib/generators/overule/install/templates/create_overule_rule_activities.rb.tt +15 -0
  33. data/lib/generators/overule/install/templates/create_overule_rule_versions.rb.tt +28 -0
  34. data/lib/generators/overule/install/templates/create_overule_rules.rb.tt +13 -0
  35. data/lib/generators/overule/install/templates/overule.rb.tt +35 -0
  36. data/lib/overule/action.rb +22 -0
  37. data/lib/overule/condition.rb +40 -0
  38. data/lib/overule/configuration.rb +75 -0
  39. data/lib/overule/context.rb +22 -0
  40. data/lib/overule/engine.rb +7 -0
  41. data/lib/overule/inference.rb +38 -0
  42. data/lib/overule/operator.rb +25 -0
  43. data/lib/overule/version.rb +3 -0
  44. data/lib/overule.rb +13 -0
  45. metadata +103 -0
@@ -0,0 +1,95 @@
1
+ <%= form_with model: rule, url: rule.persisted? ? rule_path(rule) : rules_path, local: true, html: { "x-data" => "ruleBuilder(#{rule.definition.to_json})", class: "space-y-6" } do |f| %>
2
+ <% if rule.errors.any? %>
3
+ <div class="bg-rose-50 border border-rose-200 text-rose-900 px-4 py-3 rounded">
4
+ <p class="font-medium mb-1"><%= pluralize(rule.errors.count, "error") %> prevented saving:</p>
5
+ <ul class="list-disc list-inside text-sm">
6
+ <% rule.errors.full_messages.each do |msg| %>
7
+ <li><%= msg %></li>
8
+ <% end %>
9
+ </ul>
10
+ </div>
11
+ <% end %>
12
+
13
+ <section class="bg-white border border-slate-200 rounded-lg p-5 space-y-4">
14
+ <div>
15
+ <%= f.label :name, class: "block text-sm font-medium text-slate-700 mb-1" %>
16
+ <%= f.text_field :name, required: true, class: "w-full border border-slate-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900/20" %>
17
+ </div>
18
+ <div>
19
+ <%= f.label :description, class: "block text-sm font-medium text-slate-700 mb-1" %>
20
+ <%= f.text_area :description, rows: 2, class: "w-full border border-slate-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900/20" %>
21
+ </div>
22
+ <div class="flex items-center gap-2">
23
+ <%= f.check_box :enabled, class: "rounded border-slate-300" %>
24
+ <%= f.label :enabled, class: "text-sm text-slate-700" %>
25
+ </div>
26
+ </section>
27
+
28
+ <section class="bg-white border border-slate-200 rounded-lg p-5">
29
+ <h2 class="text-sm font-medium text-slate-600 mb-3">When (conditions)</h2>
30
+ <template x-if="whenNode">
31
+ <div x-data="ruleGroup(whenNode)">
32
+ <%= render "group", depth: 0 %>
33
+ </div>
34
+ </template>
35
+ </section>
36
+
37
+ <section class="bg-white border border-slate-200 rounded-lg p-5 space-y-3">
38
+ <h2 class="text-sm font-medium text-slate-600">Then ($static action)</h2>
39
+ <p class="text-xs text-slate-500">Outputs returned when the rule matches. Each value has a datatype; <code>array</code> and <code>object</code> can be nested recursively.</p>
40
+
41
+ <div class="space-y-3">
42
+ <template x-for="(entry, i) in staticEntries" :key="i">
43
+ <div class="border border-slate-200 rounded-lg bg-white shadow-sm" x-data="staticNode(entry.node)">
44
+ <div class="flex items-center gap-2 px-4 py-2.5 border-b border-slate-100 bg-slate-50/60 rounded-t-lg">
45
+ <span class="text-xs text-slate-400 font-mono select-none">key</span>
46
+ <input type="text" placeholder="output name" x-model="entry.k"
47
+ class="flex-1 bg-transparent border-0 px-0 text-sm font-mono font-medium text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-0">
48
+ <select x-model="node.kind" @change="onKindChange()"
49
+ class="border border-slate-300 rounded px-2 py-1 text-xs font-mono bg-white">
50
+ <option value="string">string</option>
51
+ <option value="number">number</option>
52
+ <option value="boolean">boolean</option>
53
+ <option value="null">null</option>
54
+ <option value="array">array</option>
55
+ <option value="object">object</option>
56
+ </select>
57
+ <button type="button" @click="$parent.removeStaticEntry(i)"
58
+ class="text-rose-600 hover:bg-rose-50 rounded px-2 py-1">&times;</button>
59
+ </div>
60
+ <div class="px-4 py-3">
61
+ <%= render "static_node", depth: 0 %>
62
+ </div>
63
+ </div>
64
+ </template>
65
+ </div>
66
+
67
+ <button type="button" @click="addStaticEntry()"
68
+ class="text-sm px-3 py-1.5 rounded border border-dashed border-slate-300 hover:border-slate-400 hover:bg-slate-50 text-slate-600">
69
+ + Add output
70
+ </button>
71
+ </section>
72
+
73
+ <section class="bg-slate-900 text-slate-100 rounded-lg p-4" x-data="{ copied: false }">
74
+ <details>
75
+ <summary class="text-xs uppercase tracking-wide text-slate-400 cursor-pointer">JSON preview</summary>
76
+ <div class="relative mt-3">
77
+ <button type="button"
78
+ @click="navigator.clipboard.writeText(JSON.stringify(computedRoot, null, 2)).then(() => { copied = true; setTimeout(() => copied = false, 1500) })"
79
+ class="absolute top-2 right-2 text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">
80
+ <span x-text="copied ? 'Copied!' : 'Copy'"></span>
81
+ </button>
82
+ <pre class="text-xs overflow-x-auto pr-16"><code x-text="JSON.stringify(computedRoot, null, 2)"></code></pre>
83
+ </div>
84
+ </details>
85
+ </section>
86
+
87
+ <%= f.hidden_field :definition, "x-bind:value": "serialized" %>
88
+
89
+ <div class="flex items-center gap-3">
90
+ <%= f.submit (rule.persisted? ? "Save changes" : "Create rule"),
91
+ class: "px-4 py-2 rounded bg-slate-900 text-white text-sm hover:bg-slate-700 cursor-pointer" %>
92
+ <%= link_to "Cancel", (rule.persisted? ? rule_path(rule) : rules_path),
93
+ class: "px-4 py-2 rounded text-sm text-slate-600 hover:bg-slate-100" %>
94
+ </div>
95
+ <% end %>
@@ -0,0 +1,106 @@
1
+ <%# Recursive group partial. Expects an enclosing element with x-data="ruleGroup(node)". %>
2
+ <%# ERB recursion is bounded by MAX_DEPTH so we can pre-render enough levels for users to nest into; %>
3
+ <%# anything beyond MAX_DEPTH is reachable only after the user reloads against a deeper saved rule. %>
4
+ <% depth = local_assigns.fetch(:depth, 0) %>
5
+ <% max_depth = local_assigns.fetch(:max_depth, 6) %>
6
+ <div class="<%= "border-l-2 pl-3 ml-1" unless depth.zero? %> <%= depth.zero? ? "" : "border-slate-200" %>">
7
+ <div class="flex items-center gap-2 mb-3">
8
+ <label class="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-wide">
9
+ <span>Match</span>
10
+ <select x-model="node.op"
11
+ class="border border-slate-300 rounded px-2 py-1 text-xs normal-case tracking-normal">
12
+ <option value="and">AND</option>
13
+ <option value="or">OR</option>
14
+ </select>
15
+ </label>
16
+ <span class="text-xs text-slate-500">of the following</span>
17
+ </div>
18
+
19
+ <div class="space-y-2">
20
+ <template x-for="(c, i) in node.cond" :key="i">
21
+ <div class="flex flex-wrap gap-2 items-start bg-slate-50 border border-slate-200 rounded p-2">
22
+ <input type="text" x-model="c.var" placeholder="variable"
23
+ class="flex-1 min-w-[120px] border border-slate-300 rounded px-2 py-1.5 text-sm font-mono">
24
+
25
+ <select x-model="c.datatype" @change="onDatatypeChange(i)"
26
+ class="border border-slate-300 rounded px-2 py-1.5 text-sm">
27
+ <option value="string">string</option>
28
+ <option value="select">select</option>
29
+ <option value="array">array</option>
30
+ <option value="number">number</option>
31
+ <option value="integer">integer</option>
32
+ <option value="float">float</option>
33
+ <option value="decimal">decimal</option>
34
+ </select>
35
+
36
+ <select x-model="c.op" @change="onOperatorChange(i)"
37
+ x-effect="$nextTick(() => { if ($el.value !== c.op) $el.value = c.op; })"
38
+ class="border border-slate-300 rounded px-2 py-1.5 text-sm">
39
+ <template x-for="op in operatorsFor(c.datatype)" :key="op">
40
+ <option :value="op" x-text="op"></option>
41
+ </template>
42
+ </select>
43
+
44
+ <template x-if="!isArrayValue(c)">
45
+ <input type="text" :value="valueText(i)" @input="setValue(i, $event.target.value)"
46
+ placeholder="value"
47
+ class="flex-[2] min-w-[160px] border border-slate-300 rounded px-2 py-1.5 text-sm font-mono">
48
+ </template>
49
+
50
+ <template x-if="isArrayValue(c)">
51
+ <div class="flex-[2] min-w-[160px] space-y-1.5 border-l-2 border-slate-200 pl-3">
52
+ <template x-for="(item, j) in c.value" :key="j">
53
+ <div class="flex gap-2 items-start">
54
+ <span class="text-xs text-slate-400 font-mono mt-2 select-none w-6 text-right" x-text="'[' + j + ']'"></span>
55
+ <input type="text" :value="valueItemText(i, j)" @input="setValueItem(i, j, $event.target.value)"
56
+ placeholder="value"
57
+ class="flex-1 border border-slate-300 rounded px-2 py-1.5 text-sm font-mono">
58
+ <button type="button" @click="removeValueItem(i, j)"
59
+ class="px-2 py-1.5 text-rose-600 hover:bg-rose-50 rounded">&times;</button>
60
+ </div>
61
+ </template>
62
+ <button type="button" @click="addValueItem(i)"
63
+ class="text-xs px-2 py-1 rounded border border-dashed border-slate-300 hover:border-slate-400 hover:bg-slate-50 text-slate-600">
64
+ + Item
65
+ </button>
66
+ </div>
67
+ </template>
68
+
69
+ <button type="button" @click="removeCondition(i)"
70
+ class="px-2 py-1.5 text-rose-600 hover:bg-rose-50 rounded">&times;</button>
71
+ </div>
72
+ </template>
73
+
74
+ <% if depth < max_depth %>
75
+ <template x-for="(child, i) in node.set" :key="i">
76
+ <div class="bg-white border border-slate-200 rounded p-2" x-data="ruleGroup(child)">
77
+ <div class="flex items-center justify-between mb-2">
78
+ <span class="text-xs text-slate-500">Nested group</span>
79
+ <button type="button" @click="$parent.removeGroup(i)"
80
+ class="px-2 py-1 text-rose-600 text-xs hover:bg-rose-50 rounded">Remove group</button>
81
+ </div>
82
+ <%= render "group", depth: depth + 1, max_depth: max_depth %>
83
+ </div>
84
+ </template>
85
+ <% else %>
86
+ <template x-if="node.set.length > 0">
87
+ <div class="bg-amber-50 border border-amber-200 text-amber-900 text-xs rounded p-2">
88
+ Nesting depth limit reached (<%= max_depth %>). Save and reload to edit deeper levels.
89
+ </div>
90
+ </template>
91
+ <% end %>
92
+ </div>
93
+
94
+ <div class="flex gap-2 mt-3">
95
+ <button type="button" @click="addCondition"
96
+ class="text-sm px-3 py-1.5 rounded border border-slate-300 hover:bg-slate-100">
97
+ + Condition
98
+ </button>
99
+ <% if depth < max_depth %>
100
+ <button type="button" @click="addGroup"
101
+ class="text-sm px-3 py-1.5 rounded border border-slate-300 hover:bg-slate-100">
102
+ + Nested group
103
+ </button>
104
+ <% end %>
105
+ </div>
106
+ </div>
@@ -0,0 +1,79 @@
1
+ <%# Recursive typed-value editor. Expects enclosing element with x-data="staticNode(node)". %>
2
+ <%# Single recursive render site so server-side renders grow linearly with max_depth. %>
3
+ <% depth = local_assigns.fetch(:depth, 0) %>
4
+ <% max_depth = local_assigns.fetch(:max_depth, 5) %>
5
+
6
+ <template x-if="node.kind === 'string'">
7
+ <input type="text" x-model="node.value"
8
+ class="w-full border border-slate-300 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-slate-900/15">
9
+ </template>
10
+
11
+ <template x-if="node.kind === 'number'">
12
+ <input type="number" step="any" :value="node.value"
13
+ @input="node.value = $event.target.value === '' ? 0 : Number($event.target.value)"
14
+ class="w-full border border-slate-300 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-slate-900/15">
15
+ </template>
16
+
17
+ <template x-if="node.kind === 'boolean'">
18
+ <select :value="String(node.value)"
19
+ @change="node.value = $event.target.value === 'true'"
20
+ class="border border-slate-300 rounded px-3 py-1.5 text-sm font-mono bg-white">
21
+ <option value="true">true</option>
22
+ <option value="false">false</option>
23
+ </select>
24
+ </template>
25
+
26
+ <template x-if="node.kind === 'null'">
27
+ <span class="inline-block text-slate-400 text-sm italic font-mono px-2 py-1.5">null</span>
28
+ </template>
29
+
30
+ <%# Array and object share one recursive branch — only the child accessor and labels differ. %>
31
+ <template x-if="node.kind === 'array' || node.kind === 'object'">
32
+ <div class="space-y-1.5 border-l-2 border-slate-200 pl-3 w-full">
33
+ <template x-for="(entry, idx) in node.value" :key="idx">
34
+ <div class="flex gap-2 items-start flex-wrap"
35
+ x-data="staticNode($parent.node.kind === 'array' ? entry : entry.node)">
36
+
37
+ <template x-if="$parent.node.kind === 'array'">
38
+ <span class="text-xs text-slate-400 font-mono mt-2 select-none w-6 text-right" x-text="'[' + idx + ']'"></span>
39
+ </template>
40
+ <template x-if="$parent.node.kind === 'object'">
41
+ <input type="text" x-model="$parent.node.value[idx].k" placeholder="key"
42
+ class="w-32 border border-slate-300 rounded px-2 py-1.5 text-sm font-mono">
43
+ </template>
44
+
45
+ <select x-model="node.kind" @change="onKindChange()"
46
+ class="border border-slate-300 rounded px-2 py-1.5 text-xs font-mono bg-white">
47
+ <option value="string">string</option>
48
+ <option value="number">number</option>
49
+ <option value="boolean">boolean</option>
50
+ <option value="null">null</option>
51
+ <option value="array">array</option>
52
+ <option value="object">object</option>
53
+ </select>
54
+
55
+ <div class="flex-1 min-w-[160px]">
56
+ <% if depth < max_depth %>
57
+ <%= render "static_node", depth: depth + 1, max_depth: max_depth %>
58
+ <% else %>
59
+ <div class="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1">
60
+ Max nesting depth (<%= max_depth %>) reached. Save and reload to edit deeper levels.
61
+ </div>
62
+ <% end %>
63
+ </div>
64
+
65
+ <button type="button"
66
+ @click="$parent.node.kind === 'array' ? $parent.removeArrayItem(idx) : $parent.removeObjectEntry(idx)"
67
+ class="px-2 py-1.5 text-rose-600 hover:bg-rose-50 rounded">&times;</button>
68
+ </div>
69
+ </template>
70
+
71
+ <% if depth < max_depth %>
72
+ <button type="button"
73
+ @click="node.kind === 'array' ? addArrayItem() : addObjectEntry()"
74
+ class="text-xs px-2 py-1 rounded border border-dashed border-slate-300 hover:border-slate-400 hover:bg-slate-50 text-slate-600">
75
+ <span x-text="node.kind === 'array' ? '+ Item' : '+ Field'"></span>
76
+ </button>
77
+ <% end %>
78
+ </div>
79
+ </template>
@@ -0,0 +1,2 @@
1
+ <h1 class="text-2xl font-semibold mb-6">Edit <%= @rule.name %></h1>
2
+ <%= render "form", rule: @rule %>
@@ -0,0 +1,45 @@
1
+ <div class="flex items-center justify-between mb-6">
2
+ <h1 class="text-2xl font-semibold">Rules</h1>
3
+ <%= link_to "New rule", new_rule_path, class: "px-4 py-2 rounded bg-slate-900 text-white text-sm hover:bg-slate-700" %>
4
+ </div>
5
+
6
+ <% if @rules.empty? %>
7
+ <div class="bg-white border border-dashed border-slate-300 rounded-lg p-12 text-center text-slate-500">
8
+ No rules yet. <%= link_to "Create the first one", new_rule_path, class: "text-slate-900 underline" %>.
9
+ </div>
10
+ <% else %>
11
+ <div class="bg-white border border-slate-200 rounded-lg overflow-hidden">
12
+ <table class="min-w-full text-sm">
13
+ <thead class="bg-slate-50 text-slate-600 text-left">
14
+ <tr>
15
+ <th class="px-4 py-2 font-medium">Name</th>
16
+ <th class="px-4 py-2 font-medium">Description</th>
17
+ <th class="px-4 py-2 font-medium">Enabled</th>
18
+ <th class="px-4 py-2 font-medium">Updated</th>
19
+ <th class="px-4 py-2"></th>
20
+ </tr>
21
+ </thead>
22
+ <tbody class="divide-y divide-slate-200">
23
+ <% @rules.each do |rule| %>
24
+ <tr>
25
+ <td class="px-4 py-3 font-medium"><%= link_to rule.name, rule, class: "hover:underline" %></td>
26
+ <td class="px-4 py-3 text-slate-500"><%= truncate(rule.description.to_s, length: 60) %></td>
27
+ <td class="px-4 py-3">
28
+ <span class="<%= rule.enabled? ? "text-emerald-700" : "text-slate-400" %>">
29
+ <%= rule.enabled? ? "yes" : "no" %>
30
+ </span>
31
+ </td>
32
+ <td class="px-4 py-3 text-slate-500"><%= rule.updated_at.strftime("%Y-%m-%d %H:%M") %></td>
33
+ <td class="px-4 py-3 text-right space-x-2">
34
+ <%= link_to "Edit", edit_rule_path(rule), class: "text-slate-700 hover:underline" %>
35
+ <%= button_to "Delete", rule, method: :delete,
36
+ data: { turbo_confirm: "Delete #{rule.name}?" },
37
+ form: { class: "inline" },
38
+ class: "text-rose-600 hover:underline bg-transparent border-0 p-0 cursor-pointer" %>
39
+ </td>
40
+ </tr>
41
+ <% end %>
42
+ </tbody>
43
+ </table>
44
+ </div>
45
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <h1 class="text-2xl font-semibold mb-6">New rule</h1>
2
+ <%= render "form", rule: @rule %>
@@ -0,0 +1,54 @@
1
+ <div class="flex items-start justify-between mb-6 gap-4">
2
+ <div class="min-w-0">
3
+ <div class="flex items-center gap-2 mb-1">
4
+ <h1 class="text-2xl font-semibold truncate"><%= @rule.name %></h1>
5
+ <span class="<%= @rule.enabled? ? "bg-emerald-100 text-emerald-800 border-emerald-200" : "bg-slate-100 text-slate-600 border-slate-200" %> text-xs font-medium uppercase tracking-wide border rounded px-2 py-0.5">
6
+ <%= @rule.enabled? ? "enabled" : "disabled" %>
7
+ </span>
8
+ </div>
9
+ <p class="text-slate-500 text-sm"><%= @rule.description.presence || "No description." %></p>
10
+ <p class="text-xs text-slate-400 mt-1 font-mono">
11
+ created <%= @rule.created_at.strftime("%Y-%m-%d %H:%M") %>
12
+ · updated <%= @rule.updated_at.strftime("%Y-%m-%d %H:%M") %>
13
+ · <%= pluralize(@rule.activities.count, "activity entry") %>
14
+ · <%= link_to pluralize(@rule.versions.count, "version"), rule_versions_path(@rule), class: "hover:underline" %>
15
+ </p>
16
+ </div>
17
+ <div class="flex gap-2 shrink-0">
18
+ <%= link_to "Edit", edit_rule_path(@rule), class: "px-3 py-1.5 text-sm rounded border border-slate-300 hover:bg-slate-100" %>
19
+ <%= button_to "Delete", @rule, method: :delete,
20
+ data: { turbo_confirm: "Delete #{@rule.name}?" },
21
+ class: "px-3 py-1.5 text-sm rounded bg-rose-600 text-white hover:bg-rose-700 cursor-pointer" %>
22
+ </div>
23
+ </div>
24
+
25
+ <div class="grid grid-cols-1 lg:grid-cols-5 gap-4">
26
+ <section class="bg-white border border-slate-200 rounded-lg p-4 lg:col-span-3" x-data="{ copied: false }">
27
+ <div class="flex items-center justify-between mb-2">
28
+ <h2 class="text-sm font-medium text-slate-600">Definition</h2>
29
+ <button type="button"
30
+ @click="navigator.clipboard.writeText($refs.def.textContent).then(() => { copied = true; setTimeout(() => copied = false, 1500) })"
31
+ class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">
32
+ <span x-text="copied ? 'Copied!' : 'Copy'"></span>
33
+ </button>
34
+ </div>
35
+ <pre class="text-xs bg-slate-50 border border-slate-200 rounded p-3 overflow-x-auto"><code x-ref="def"><%= JSON.pretty_generate(@rule.definition) %></code></pre>
36
+ </section>
37
+
38
+ <section class="bg-white border border-slate-200 rounded-lg p-4 lg:col-span-2">
39
+ <div class="flex items-center justify-between mb-3">
40
+ <h2 class="text-sm font-medium text-slate-600">Activities</h2>
41
+ <span class="text-xs text-slate-400"><%= @rule.activities.count %> total</span>
42
+ </div>
43
+ <% activities = @rule.activities.recent.limit(100) %>
44
+ <% if activities.empty? %>
45
+ <p class="text-sm text-slate-400 italic">No activity recorded.</p>
46
+ <% else %>
47
+ <div class="space-y-2 max-h-[640px] overflow-y-auto pr-1">
48
+ <% activities.each do |activity| %>
49
+ <%= render "overule/activities/activity", activity: activity %>
50
+ <% end %>
51
+ </div>
52
+ <% end %>
53
+ </section>
54
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,8 @@
1
+ Overule::Engine.routes.draw do
2
+ resources :rules do
3
+ resources :activities, only: [:index], controller: "activities"
4
+ resources :versions, only: [:index, :show], controller: "rule_versions"
5
+ end
6
+ resources :activities, only: [:index]
7
+ root to: "rules#index"
8
+ end
@@ -0,0 +1,25 @@
1
+ Description:
2
+ Copies the Overule configuration initializer (and ActiveRecord migrations,
3
+ when applicable) into your host Rails application.
4
+
5
+ Examples:
6
+ bin/rails generate overule:install
7
+
8
+ Creates:
9
+ config/initializers/overule.rb
10
+ db/migrate/<timestamp>_create_overule_rules.rb
11
+ db/migrate/<timestamp>_create_overule_rule_activities.rb
12
+ db/migrate/<timestamp>_create_overule_rule_versions.rb
13
+ db/migrate/<timestamp>_add_rule_version_to_overule_rule_activities.rb
14
+
15
+ Run `bin/rails db:migrate` afterward.
16
+
17
+ bin/rails generate overule:install --orm=mongoid
18
+
19
+ Creates:
20
+ config/initializers/overule.rb (with config.orm = :mongoid set)
21
+
22
+ No migrations are emitted — Mongoid uses model-declared indexes. After
23
+ install, run `bin/rails db:mongoid:create_indexes`.
24
+
25
+ Safe to re-run after upgrading the gem: existing files are detected and skipped.
@@ -0,0 +1,64 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Overule
5
+ module Generators
6
+ class InstallGenerator < ::Rails::Generators::Base
7
+ include ::Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ class_option :orm, type: :string, default: "active_record",
12
+ desc: "ORM to back Overule (active_record or mongoid)"
13
+
14
+ desc "Copies the Overule configuration initializer (and migrations for ActiveRecord) into the host application"
15
+
16
+ def self.next_migration_number(dirname)
17
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
18
+ end
19
+
20
+ def validate_orm
21
+ return if %w[active_record mongoid].include?(selected_orm)
22
+
23
+ raise ::Thor::Error, "Unsupported --orm=#{options[:orm]}. Use 'active_record' or 'mongoid'."
24
+ end
25
+
26
+ def copy_initializer
27
+ template "overule.rb.tt", "config/initializers/overule.rb"
28
+ end
29
+
30
+ def copy_migrations
31
+ if selected_orm == "mongoid"
32
+ return say_status(:skip,
33
+ "migrations (mongoid uses model-declared indexes; run `rails db:mongoid:create_indexes` after install)",
34
+ :yellow)
35
+ end
36
+
37
+ migration_template "create_overule_rules.rb.tt",
38
+ "db/migrate/create_overule_rules.rb"
39
+ migration_template "create_overule_rule_activities.rb.tt",
40
+ "db/migrate/create_overule_rule_activities.rb"
41
+ migration_template "create_overule_rule_versions.rb.tt",
42
+ "db/migrate/create_overule_rule_versions.rb"
43
+ migration_template "add_rule_version_to_overule_rule_activities.rb.tt",
44
+ "db/migrate/add_rule_version_to_overule_rule_activities.rb"
45
+ end
46
+
47
+ no_tasks do
48
+ # Used by the migration .tt templates to declare `class … < ActiveRecord::Migration[X.Y]`
49
+ # against whatever AR is loaded in the host app.
50
+ def migration_version
51
+ "ActiveRecord::Migration[#{::Rails::VERSION::MAJOR}.#{::Rails::VERSION::MINOR}]"
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ # Host apps configure `config.generators.orm :active_record` which makes
58
+ # Rails pass the value as a Symbol; CLI users pass it as a String. Normalize.
59
+ def selected_orm
60
+ options[:orm].to_s
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,21 @@
1
+ class AddRuleVersionToOveruleRuleActivities < <%= migration_version %>
2
+ def up
3
+ add_reference :overule_rule_activities, :rule_version, foreign_key: false, index: true
4
+
5
+ # Backfill: link any pre-existing activities to their rule's v1 snapshot
6
+ # (the row created by CreateOveruleRuleVersions).
7
+ execute(<<~SQL.squish)
8
+ UPDATE overule_rule_activities
9
+ SET rule_version_id = (
10
+ SELECT id FROM overule_rule_versions
11
+ WHERE overule_rule_versions.rule_id = overule_rule_activities.rule_id
12
+ AND overule_rule_versions.version = 1
13
+ )
14
+ WHERE rule_id IS NOT NULL AND rule_version_id IS NULL
15
+ SQL
16
+ end
17
+
18
+ def down
19
+ remove_reference :overule_rule_activities, :rule_version
20
+ end
21
+ end
@@ -0,0 +1,15 @@
1
+ class CreateOveruleRuleActivities < <%= migration_version %>
2
+ def change
3
+ create_table :overule_rule_activities do |t|
4
+ t.references :rule, foreign_key: false, index: true
5
+ t.string :rule_name, null: false
6
+ t.string :action, null: false
7
+ t.string :actor
8
+ t.json :diff, null: false, default: {}
9
+ t.datetime :created_at, null: false, precision: 6
10
+ end
11
+
12
+ add_index :overule_rule_activities, :created_at
13
+ add_index :overule_rule_activities, :action
14
+ end
15
+ end
@@ -0,0 +1,28 @@
1
+ class CreateOveruleRuleVersions < <%= migration_version %>
2
+ def up
3
+ create_table :overule_rule_versions do |t|
4
+ t.references :rule, foreign_key: false, index: true
5
+ t.string :rule_name, null: false
6
+ t.integer :version, null: false
7
+ t.string :name, null: false
8
+ t.text :description
9
+ t.boolean :enabled, null: false, default: true
10
+ t.json :definition, null: false, default: {}
11
+ t.datetime :created_at, null: false, precision: 6
12
+ end
13
+ add_index :overule_rule_versions, [:rule_id, :version], unique: true
14
+
15
+ # Backfill: capture each existing rule's current state as version 1 so
16
+ # version history is contiguous going forward.
17
+ execute(<<~SQL.squish)
18
+ INSERT INTO overule_rule_versions
19
+ (rule_id, rule_name, version, name, description, enabled, definition, created_at)
20
+ SELECT id, name, 1, name, description, enabled, definition, created_at
21
+ FROM overule_rules
22
+ SQL
23
+ end
24
+
25
+ def down
26
+ drop_table :overule_rule_versions
27
+ end
28
+ end
@@ -0,0 +1,13 @@
1
+ class CreateOveruleRules < <%= migration_version %>
2
+ def change
3
+ create_table :overule_rules do |t|
4
+ t.string :name, null: false
5
+ t.text :description
6
+ t.json :definition, null: false, default: {}
7
+ t.boolean :enabled, null: false, default: true
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :overule_rules, :name, unique: true
12
+ end
13
+ end
@@ -0,0 +1,35 @@
1
+ # Overule configuration
2
+ #
3
+ # Customize how Overule behaves in this host application. Every block below is
4
+ # optional — uncomment what you need.
5
+
6
+ Overule.configure do |config|
7
+ # ORM that backs Overule::Rule, Overule::RuleActivity, Overule::RuleVersion.
8
+ #
9
+ # Supported values:
10
+ # :active_record — uses ActiveRecord and the bundled migrations.
11
+ # :mongoid — uses Mongoid; no SQL migrations needed. After install,
12
+ # run `bin/rails db:mongoid:create_indexes` so the unique
13
+ # indexes declared on the models are created.
14
+ <% if options[:orm].to_s == "mongoid" -%>
15
+ config.orm = :mongoid
16
+ <% else -%>
17
+ # config.orm = :active_record
18
+ <% end -%>
19
+
20
+ # Attribute every rule change (create / update / destroy) to a user in the
21
+ # activity log. The proc receives the current Overule::ApplicationController
22
+ # and should return a string identifier — typically an email, username, or
23
+ # external user id. When unset, activities are stored with `actor: nil` and
24
+ # rendered as "anonymous" in the UI.
25
+ #
26
+ # config.actor_proc = ->(controller) { controller.current_user&.email }
27
+
28
+ # Gate the Overule UI behind HTTP Basic auth. Defaults to false (no gate;
29
+ # the engine relies on whatever auth the host app already provides). When
30
+ # enabled, both username and password are constant-time compared.
31
+ #
32
+ # config.http_basic_auth = true
33
+ # config.http_basic_auth_username = ENV.fetch("OVERULE_HTTP_BASIC_USERNAME")
34
+ # config.http_basic_auth_password = ENV.fetch("OVERULE_HTTP_BASIC_PASSWORD")
35
+ end
@@ -0,0 +1,22 @@
1
+ module Overule
2
+ # Handles the execution of actions defined in rules
3
+ # Actions can be static assignments or dynamic operations to be performed
4
+ # when conditions are met
5
+ class Action
6
+ attr_reader :config, :ctx
7
+
8
+ def initialize(config, ctx)
9
+ @config = config
10
+ @ctx = ctx
11
+ end
12
+
13
+ def fire
14
+ # Only static for now
15
+ config["$static"]
16
+ end
17
+
18
+ def fire!
19
+ # Implement
20
+ end
21
+ end
22
+ end