pg_sql_triggers 1.0.0 → 1.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.erb_lint.yml +47 -0
  3. data/.rubocop.yml +4 -1
  4. data/CHANGELOG.md +112 -1
  5. data/COVERAGE.md +58 -0
  6. data/Goal.md +450 -123
  7. data/README.md +53 -215
  8. data/app/controllers/pg_sql_triggers/application_controller.rb +46 -0
  9. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -1
  10. data/app/controllers/pg_sql_triggers/generator_controller.rb +76 -8
  11. data/app/controllers/pg_sql_triggers/migrations_controller.rb +18 -0
  12. data/app/models/pg_sql_triggers/trigger_registry.rb +93 -12
  13. data/app/views/layouts/pg_sql_triggers/application.html.erb +34 -1
  14. data/app/views/pg_sql_triggers/dashboard/index.html.erb +70 -30
  15. data/app/views/pg_sql_triggers/generator/new.html.erb +22 -4
  16. data/app/views/pg_sql_triggers/generator/preview.html.erb +244 -16
  17. data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +221 -0
  18. data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +40 -0
  19. data/app/views/pg_sql_triggers/tables/index.html.erb +0 -2
  20. data/app/views/pg_sql_triggers/tables/show.html.erb +3 -4
  21. data/config/initializers/pg_sql_triggers.rb +69 -0
  22. data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +3 -1
  23. data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +8 -0
  24. data/docs/README.md +66 -0
  25. data/docs/api-reference.md +681 -0
  26. data/docs/configuration.md +541 -0
  27. data/docs/getting-started.md +135 -0
  28. data/docs/kill-switch.md +586 -0
  29. data/docs/screenshots/.gitkeep +1 -0
  30. data/docs/screenshots/Generate Trigger.png +0 -0
  31. data/docs/screenshots/Triggers Page.png +0 -0
  32. data/docs/screenshots/kill error.png +0 -0
  33. data/docs/screenshots/kill modal for migration down.png +0 -0
  34. data/docs/usage-guide.md +493 -0
  35. data/docs/web-ui.md +353 -0
  36. data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +3 -1
  37. data/lib/generators/pg_sql_triggers/templates/initializer.rb +44 -2
  38. data/lib/pg_sql_triggers/drift/db_queries.rb +116 -0
  39. data/lib/pg_sql_triggers/drift/detector.rb +187 -0
  40. data/lib/pg_sql_triggers/drift/reporter.rb +179 -0
  41. data/lib/pg_sql_triggers/drift.rb +14 -11
  42. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +15 -1
  43. data/lib/pg_sql_triggers/generator/form.rb +3 -1
  44. data/lib/pg_sql_triggers/generator/service.rb +82 -26
  45. data/lib/pg_sql_triggers/migration.rb +1 -1
  46. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +344 -0
  47. data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +143 -0
  48. data/lib/pg_sql_triggers/migrator/safety_validator.rb +258 -0
  49. data/lib/pg_sql_triggers/migrator.rb +85 -3
  50. data/lib/pg_sql_triggers/registry/manager.rb +100 -13
  51. data/lib/pg_sql_triggers/sql/kill_switch.rb +300 -0
  52. data/lib/pg_sql_triggers/testing/dry_run.rb +5 -7
  53. data/lib/pg_sql_triggers/testing/function_tester.rb +66 -24
  54. data/lib/pg_sql_triggers/testing/safe_executor.rb +23 -11
  55. data/lib/pg_sql_triggers/testing/syntax_validator.rb +24 -1
  56. data/lib/pg_sql_triggers/version.rb +1 -1
  57. data/lib/pg_sql_triggers.rb +24 -0
  58. data/lib/tasks/trigger_migrations.rake +33 -0
  59. data/scripts/generate_coverage_report.rb +129 -0
  60. metadata +45 -5
@@ -9,17 +9,81 @@
9
9
  </ul>
10
10
  </div>
11
11
 
12
- <!-- DSL Preview -->
13
- <div style="margin-bottom: 2rem;">
14
- <h3 style="display: flex; align-items: center; gap: 0.5rem;">
15
- <span>DSL Definition</span>
16
- <span class="badge badge-info">Ruby</span>
17
- </h3>
18
- <pre style="background: #f8f9fa; padding: 1rem; border-radius: 4px; overflow-x: auto; border: 1px solid #dee2e6;"><code><%= @dsl_content %></code></pre>
19
- </div>
20
-
21
12
  <!-- Actions -->
22
- <%= form_with url: generator_index_path, method: :post, scope: :pg_sql_triggers_generator_form do |f| %>
13
+ <%= form_with url: generator_index_path, method: :post, scope: :pg_sql_triggers_generator_form, id: "generator-create-form", local: true do |f| %>
14
+ <!-- DSL Preview -->
15
+ <div style="margin-bottom: 2rem;">
16
+ <h3 style="display: flex; align-items: center; gap: 0.5rem;">
17
+ <span>DSL Definition</span>
18
+ <span class="badge badge-info">Ruby</span>
19
+ <small style="color: #6c757d; font-weight: normal;">(Updates automatically when timing or condition changes)</small>
20
+ </h3>
21
+ <pre id="dsl-preview" style="background: #f8f9fa; padding: 1rem; border-radius: 4px; overflow-x: auto; border: 1px solid #dee2e6; min-height: 150px;"><code><%= @dsl_content %></code></pre>
22
+ </div>
23
+
24
+ <!-- Trigger Configuration Summary (Editable) -->
25
+ <div style="margin-bottom: 2rem; padding: 1.5rem; background: #f8f9fa; border-radius: 4px; border: 1px solid #dee2e6;">
26
+ <h3 style="margin-top: 0; margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.5rem;">
27
+ <span>Trigger Configuration</span>
28
+ <small style="color: #6c757d; font-weight: normal;">(Editable - changes will update preview)</small>
29
+ </h3>
30
+
31
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem;">
32
+ <!-- Timing Field -->
33
+ <div>
34
+ <%= f.label :timing, "Trigger Timing *", style: "display: block; font-weight: 500; margin-bottom: 0.5rem; color: #495057;" %>
35
+ <%= f.select :timing,
36
+ options_for_select([["Before", "before"], ["After", "after"]], @form.timing || "before"),
37
+ {},
38
+ {
39
+ required: true,
40
+ id: "preview-timing-select",
41
+ style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; background: white; font-size: 0.875rem;",
42
+ onchange: "updatePreview()"
43
+ } %>
44
+ <small style="color: #6c757d; display: block; margin-top: 0.25rem;">
45
+ When the trigger should fire relative to the event
46
+ </small>
47
+ </div>
48
+
49
+ <!-- Read-only fields -->
50
+ <div>
51
+ <strong style="color: #495057; display: block; margin-bottom: 0.5rem;">Events:</strong>
52
+ <div style="padding: 0.5rem; background: white; border-radius: 4px; border: 1px solid #dee2e6;">
53
+ <span style="color: #495057; font-weight: 500;"><%= Array(@form.events).compact_blank.map(&:upcase).join(", ") %></span>
54
+ </div>
55
+ </div>
56
+
57
+ <div>
58
+ <strong style="color: #495057; display: block; margin-bottom: 0.5rem;">Table:</strong>
59
+ <div style="padding: 0.5rem; background: white; border-radius: 4px; border: 1px solid #dee2e6;">
60
+ <span style="color: #495057; font-weight: 500;"><%= @form.table_name %></span>
61
+ </div>
62
+ </div>
63
+
64
+ <div>
65
+ <strong style="color: #495057; display: block; margin-bottom: 0.5rem;">Function:</strong>
66
+ <div style="padding: 0.5rem; background: white; border-radius: 4px; border: 1px solid #dee2e6;">
67
+ <span style="color: #495057; font-weight: 500;"><%= @form.function_name %></span>
68
+ </div>
69
+ </div>
70
+
71
+ <!-- Condition Field (Editable) -->
72
+ <div style="grid-column: 1 / -1;">
73
+ <%= f.label :condition, "WHEN Condition (Optional)", style: "display: block; font-weight: 500; margin-bottom: 0.5rem; color: #495057;" %>
74
+ <%= f.text_area :condition,
75
+ value: @form.condition,
76
+ placeholder: "e.g., NEW.email IS NOT NULL OR NEW.status = 'active'",
77
+ rows: 3,
78
+ id: "preview-condition-textarea",
79
+ style: "width: 100%; padding: 0.75rem; border: 1px solid #ced4da; border-radius: 4px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; font-size: 0.875rem; background: white; resize: vertical;",
80
+ oninput: "updatePreview()" %>
81
+ <small style="color: #6c757d; display: block; margin-top: 0.25rem;">
82
+ Optional SQL condition. Leave empty to fire on all rows. Changes update the DSL preview above.
83
+ </small>
84
+ </div>
85
+ </div>
86
+ </div>
23
87
  <!-- SQL Validation Result -->
24
88
  <% if @sql_validation %>
25
89
  <div style="margin-bottom: 2rem; padding: 1rem; border-radius: 4px; <%= @sql_validation[:valid] ? 'background: #d4edda; border-left: 4px solid #28a745;' : 'background: #f8d7da; border-left: 4px solid #dc3545;' %>">
@@ -48,7 +112,7 @@
48
112
  <p style="color: #6c757d; margin-bottom: 0.5rem; font-size: 0.875rem;">
49
113
  You can edit the function body below before generating the trigger files.
50
114
  </p>
51
- <%= f.text_area :function_body,
115
+ <%= f.text_area :function_body,
52
116
  value: @function_content,
53
117
  rows: 20,
54
118
  required: true,
@@ -59,7 +123,6 @@
59
123
  <%= f.hidden_field :function_name, value: @form.function_name %>
60
124
  <%= f.hidden_field :version, value: @form.version %>
61
125
  <%= f.hidden_field :enabled, value: @form.enabled %>
62
- <%= f.hidden_field :condition, value: @form.condition %>
63
126
  <%= f.hidden_field :generate_function_stub, value: @form.generate_function_stub %>
64
127
  <% Array(@form.events).reject(&:blank?).each do |event| %>
65
128
  <%= hidden_field_tag "pg_sql_triggers_generator_form[events][]", event %>
@@ -69,9 +132,174 @@
69
132
  <% end %>
70
133
 
71
134
  <div style="display: flex; gap: 1rem;">
72
- <%= f.submit "Generate Files", class: "btn btn-success",
73
- data: { confirm: "This will create files on disk and register the trigger. Continue?" } %>
74
- <%= link_to "Back to Edit", new_generator_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none;" %>
75
- <%= link_to "Cancel", root_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none;" %>
135
+ <button type="button" onclick="showKillSwitchModal('generator-create-form')" class="btn btn-success"
136
+ style="padding: 0.75rem 1.5rem; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">
137
+ Generate Files
138
+ </button>
76
139
  </div>
77
140
  <% end %>
141
+
142
+ <!-- Separate form for "Back to Edit" to avoid nested forms -->
143
+ <div style="display: flex; gap: 1rem; margin-top: 1rem;">
144
+ <%= form_with url: preview_generator_index_path, method: :post, scope: :pg_sql_triggers_generator_form, id: "back-to-edit-form", local: true, style: "margin: 0;" do |f| %>
145
+ <%= f.hidden_field :trigger_name, value: @form.trigger_name %>
146
+ <%= f.hidden_field :table_name, value: @form.table_name %>
147
+ <%= f.hidden_field :function_name, value: @form.function_name %>
148
+ <%= f.hidden_field :version, value: @form.version %>
149
+ <%= f.hidden_field :enabled, value: @form.enabled %>
150
+ <%= f.hidden_field :generate_function_stub, value: @form.generate_function_stub %>
151
+ <%= f.hidden_field :timing, id: "back-to-edit-timing" %>
152
+ <%= f.hidden_field :condition, id: "back-to-edit-condition" %>
153
+ <%= f.hidden_field :function_body, id: "back-to-edit-function-body" %>
154
+ <% Array(@form.events).reject(&:blank?).each do |event| %>
155
+ <%= hidden_field_tag "pg_sql_triggers_generator_form[events][]", event %>
156
+ <% end %>
157
+ <% Array(@form.environments).reject(&:blank?).each do |env| %>
158
+ <%= hidden_field_tag "pg_sql_triggers_generator_form[environments][]", env %>
159
+ <% end %>
160
+ <%= hidden_field_tag :back_to_edit, "1" %>
161
+ <button type="submit" class="btn" style="background: #6c757d; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer;">
162
+ Back to Edit
163
+ </button>
164
+ <% end %>
165
+ <%= link_to "Cancel", root_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 4px;" %>
166
+ </div>
167
+
168
+ <%= render 'pg_sql_triggers/shared/confirmation_modal',
169
+ operation: :ui_trigger_generate,
170
+ form_id: 'generator-create-form',
171
+ title: 'Generate Trigger Files',
172
+ message: 'This will create files on disk and register the trigger. Continue?' %>
173
+
174
+ <script>
175
+ (function() {
176
+ 'use strict';
177
+
178
+ // Store the original DSL content
179
+ const originalDsl = <%= raw @dsl_content.to_json %>;
180
+
181
+ // Extract base DSL parts
182
+ const triggerName = '<%= @form.trigger_name %>';
183
+ const tableName = '<%= @form.table_name %>';
184
+ const functionName = '<%= @form.function_name %>';
185
+ const eventsList = '<%= Array(@form.events).compact_blank.map { |e| ":#{e}" }.join(", ") %>';
186
+ const version = <%= @form.version %>;
187
+ const enabled = <%= @form.enabled %>;
188
+ const environmentsList = '<%= @form.environments.compact_blank.map { |e| ":#{e}" }.join(", ") %>';
189
+ const functionRef = /^[a-z0-9_]+$/.test(functionName) ? `:${functionName}` : `"${functionName}"`;
190
+
191
+ function updatePreview() {
192
+ const timingSelect = document.getElementById('preview-timing-select');
193
+ const conditionTextarea = document.getElementById('preview-condition-textarea');
194
+ const dslPreview = document.getElementById('dsl-preview');
195
+
196
+ if (!timingSelect || !conditionTextarea || !dslPreview) return;
197
+
198
+ const timing = timingSelect.value || 'before';
199
+ const condition = conditionTextarea.value.trim();
200
+
201
+ // Build DSL content
202
+ const now = new Date();
203
+ const timestamp = now.toISOString().slice(0, 19).replace('T', ' ');
204
+
205
+ let dslContent = `# frozen_string_literal: true
206
+
207
+ # Generated by pg_sql_triggers on ${timestamp}
208
+ PgSqlTriggers::DSL.pg_sql_trigger "${triggerName}" do
209
+ table :${tableName}
210
+ on ${eventsList}
211
+ function ${functionRef}
212
+
213
+ version ${version}
214
+ enabled ${enabled}
215
+ timing :${timing}`;
216
+
217
+ if (environmentsList && environmentsList.length > 0) {
218
+ dslContent += `\n when_env ${environmentsList}`;
219
+ }
220
+
221
+ if (condition) {
222
+ // Escape quotes in condition
223
+ const escapedCondition = condition.replace(/"/g, '\\"');
224
+ dslContent += `\n when_condition "${escapedCondition}"`;
225
+ }
226
+
227
+ dslContent += `\nend\n`;
228
+
229
+ // Update the preview
230
+ const codeElement = dslPreview.querySelector('code');
231
+ if (codeElement) {
232
+ codeElement.textContent = dslContent;
233
+ }
234
+ }
235
+
236
+ // Sync form values to back-to-edit form before submission
237
+ function syncBackToEditForm() {
238
+ const timingSelect = document.getElementById('preview-timing-select');
239
+ const conditionTextarea = document.getElementById('preview-condition-textarea');
240
+ const functionBodyTextarea = document.querySelector('#generator-create-form textarea[name="pg_sql_triggers_generator_form[function_body]"]');
241
+ const backToEditForm = document.getElementById('back-to-edit-form');
242
+
243
+ if (!backToEditForm) return;
244
+
245
+ // Update timing
246
+ const backToEditTiming = document.getElementById('back-to-edit-timing');
247
+ if (backToEditTiming && timingSelect) {
248
+ backToEditTiming.value = timingSelect.value || 'before';
249
+ }
250
+
251
+ // Update condition
252
+ const backToEditCondition = document.getElementById('back-to-edit-condition');
253
+ if (backToEditCondition && conditionTextarea) {
254
+ backToEditCondition.value = conditionTextarea.value || '';
255
+ }
256
+
257
+ // Update function_body - get from the create form textarea
258
+ const backToEditFunctionBody = document.getElementById('back-to-edit-function-body');
259
+ if (backToEditFunctionBody) {
260
+ if (functionBodyTextarea) {
261
+ backToEditFunctionBody.value = functionBodyTextarea.value || '';
262
+ } else {
263
+ // Fallback: try to find by name attribute
264
+ const createFormFunctionBody = document.querySelector('textarea[name="pg_sql_triggers_generator_form[function_body]"]');
265
+ if (createFormFunctionBody) {
266
+ backToEditFunctionBody.value = createFormFunctionBody.value || '';
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ // Initialize on DOM ready
273
+ document.addEventListener('DOMContentLoaded', function() {
274
+ const timingSelect = document.getElementById('preview-timing-select');
275
+ const conditionTextarea = document.getElementById('preview-condition-textarea');
276
+ const backToEditForm = document.getElementById('back-to-edit-form');
277
+
278
+ if (timingSelect) {
279
+ timingSelect.addEventListener('change', updatePreview);
280
+ }
281
+
282
+ if (conditionTextarea) {
283
+ conditionTextarea.addEventListener('input', updatePreview);
284
+ // Also update on paste
285
+ conditionTextarea.addEventListener('paste', function() {
286
+ setTimeout(updatePreview, 10);
287
+ });
288
+ }
289
+
290
+ // Sync form values before submitting back-to-edit form
291
+ if (backToEditForm) {
292
+ backToEditForm.addEventListener('submit', function(e) {
293
+ syncBackToEditForm();
294
+ // Ensure CSRF token is included
295
+ if (typeof window.ensureCsrfToken === 'function') {
296
+ window.ensureCsrfToken(backToEditForm);
297
+ }
298
+ });
299
+ }
300
+
301
+ // Initial update
302
+ updatePreview();
303
+ });
304
+ })();
305
+ </script>
@@ -0,0 +1,221 @@
1
+ <%# Confirmation Modal for Dangerous Operations %>
2
+ <%#
3
+ Usage:
4
+ <%= render 'pg_sql_triggers/shared/confirmation_modal',
5
+ operation: :trigger_migrate_up,
6
+ form_id: 'migration-form',
7
+ title: 'Confirm Migration',
8
+ message: 'Are you sure you want to run this migration?'
9
+ %>
10
+
11
+ <%
12
+ operation = local_assigns[:operation] || :unknown_operation
13
+ form_id = local_assigns[:form_id] || 'confirmation-form'
14
+ title = local_assigns[:title] || 'Confirm Action'
15
+ message = local_assigns[:message] || 'This action requires confirmation.'
16
+
17
+ # Generate expected confirmation text
18
+ if PgSqlTriggers.respond_to?(:kill_switch_confirmation_pattern) &&
19
+ PgSqlTriggers.kill_switch_confirmation_pattern.respond_to?(:call)
20
+ expected_confirmation = PgSqlTriggers.kill_switch_confirmation_pattern.call(operation)
21
+ else
22
+ expected_confirmation = "EXECUTE #{operation.to_s.upcase}"
23
+ end
24
+ %>
25
+
26
+ <div id="kill-switch-modal-<%= form_id %>" class="kill-switch-modal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4);">
27
+ <div class="modal-content" style="background-color: #fefefe; margin: 10% auto; padding: 20px; border: 1px solid #888; border-radius: 8px; width: 80%; max-width: 600px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); box-sizing: border-box;">
28
+ <div class="modal-header" style="border-bottom: 1px solid #dee2e6; padding-bottom: 12px; margin-bottom: 16px;">
29
+ <h3 style="margin: 0; color: #dc3545; display: flex; align-items: center; gap: 8px;">
30
+ <span style="font-size: 24px;">⚠️</span>
31
+ <%= title %>
32
+ </h3>
33
+ </div>
34
+
35
+ <div class="modal-body" style="margin-bottom: 20px; box-sizing: border-box;">
36
+ <p style="color: #495057; line-height: 1.6;"><%= message %></p>
37
+
38
+ <% if kill_switch_active? %>
39
+ <div style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 12px; margin: 16px 0; box-sizing: border-box;">
40
+ <p style="margin: 0 0 8px 0; font-weight: bold; color: #856404;">
41
+ 🛡️ Kill Switch Protection Active
42
+ </p>
43
+ <p style="margin: 0 0 12px 0; color: #856404; font-size: 14px;">
44
+ You must enter the exact confirmation text below to proceed:
45
+ </p>
46
+ <div style="background-color: #f8f9fa; border: 2px solid #ffc107; border-radius: 4px; padding: 8px; font-family: monospace; font-size: 14px; font-weight: bold; text-align: center; color: #856404; word-break: break-word; box-sizing: border-box;">
47
+ <%= expected_confirmation %>
48
+ </div>
49
+ </div>
50
+
51
+ <div style="margin-top: 16px; box-sizing: border-box;">
52
+ <label for="confirmation-text-<%= form_id %>" style="display: block; margin-bottom: 8px; font-weight: bold; color: #495057;">
53
+ Confirmation Text:
54
+ </label>
55
+ <input
56
+ type="text"
57
+ id="confirmation-text-<%= form_id %>"
58
+ name="confirmation_text"
59
+ class="form-control"
60
+ placeholder="Type the confirmation text above"
61
+ style="width: 100%; max-width: 100%; padding: 8px 12px; border: 1px solid #ced4da; border-radius: 4px; font-family: monospace; box-sizing: border-box;"
62
+ autocomplete="off">
63
+ <small style="display: block; margin-top: 4px; color: #6c757d;">
64
+ Case-sensitive. Must match exactly.
65
+ </small>
66
+ </div>
67
+ <% end %>
68
+ </div>
69
+
70
+ <div class="modal-footer" style="border-top: 1px solid #dee2e6; padding-top: 12px; display: flex; gap: 8px; justify-content: flex-end;">
71
+ <button
72
+ type="button"
73
+ class="btn-cancel"
74
+ onclick="closeKillSwitchModal('<%= form_id %>')"
75
+ style="padding: 8px 16px; border: 1px solid #6c757d; background-color: #6c757d; color: white; border-radius: 4px; cursor: pointer;">
76
+ Cancel
77
+ </button>
78
+
79
+ <% if kill_switch_active? %>
80
+ <button
81
+ type="button"
82
+ class="btn-confirm"
83
+ onclick="submitWithConfirmation('<%= form_id %>', '<%= expected_confirmation %>')"
84
+ style="padding: 8px 16px; border: 1px solid #dc3545; background-color: #dc3545; color: white; border-radius: 4px; cursor: pointer;">
85
+ Confirm & Proceed
86
+ </button>
87
+ <% else %>
88
+ <button
89
+ type="button"
90
+ class="btn-confirm"
91
+ onclick="submitWithoutConfirmation('<%= form_id %>')"
92
+ style="padding: 8px 16px; border: 1px solid #007bff; background-color: #007bff; color: white; border-radius: 4px; cursor: pointer;">
93
+ Proceed
94
+ </button>
95
+ <% end %>
96
+ </div>
97
+ </div>
98
+ </div>
99
+
100
+ <script>
101
+ // Only define functions once
102
+ if (typeof window.showKillSwitchModal === 'undefined') {
103
+ window.showKillSwitchModal = function(formId) {
104
+ const modal = document.getElementById('kill-switch-modal-' + formId);
105
+ if (modal) {
106
+ modal.style.display = 'block';
107
+ // Focus on confirmation input if it exists
108
+ const confirmationInput = document.getElementById('confirmation-text-' + formId);
109
+ if (confirmationInput) {
110
+ setTimeout(() => confirmationInput.focus(), 100);
111
+ }
112
+ }
113
+ };
114
+
115
+ window.closeKillSwitchModal = function(formId) {
116
+ const modal = document.getElementById('kill-switch-modal-' + formId);
117
+ if (modal) {
118
+ modal.style.display = 'none';
119
+ // Clear confirmation input
120
+ const confirmationInput = document.getElementById('confirmation-text-' + formId);
121
+ if (confirmationInput) {
122
+ confirmationInput.value = '';
123
+ }
124
+ }
125
+ };
126
+
127
+ window.submitWithConfirmation = function(formId, expectedConfirmation) {
128
+ const confirmationInput = document.getElementById('confirmation-text-' + formId);
129
+ const confirmationText = confirmationInput ? confirmationInput.value.trim() : '';
130
+
131
+ if (confirmationText !== expectedConfirmation) {
132
+ alert('Invalid confirmation text. Please type exactly: ' + expectedConfirmation);
133
+ return;
134
+ }
135
+
136
+ // Add confirmation text to the form
137
+ const form = document.getElementById(formId);
138
+ if (form) {
139
+ // Ensure CSRF token is included
140
+ if (typeof window.ensureCsrfToken === 'function') {
141
+ window.ensureCsrfToken(form);
142
+ }
143
+
144
+ // Remove any existing confirmation_text input
145
+ const existingInput = form.querySelector('input[name="confirmation_text"]');
146
+ if (existingInput && existingInput !== confirmationInput) {
147
+ existingInput.remove();
148
+ }
149
+
150
+ // Create a hidden input with the confirmation text
151
+ const hiddenInput = document.createElement('input');
152
+ hiddenInput.type = 'hidden';
153
+ hiddenInput.name = 'confirmation_text';
154
+ hiddenInput.value = confirmationText;
155
+ form.appendChild(hiddenInput);
156
+
157
+ form.submit();
158
+ }
159
+
160
+ window.closeKillSwitchModal(formId);
161
+ };
162
+
163
+ window.submitWithoutConfirmation = function(formId) {
164
+ const form = document.getElementById(formId);
165
+ if (form) {
166
+ // Ensure CSRF token is included
167
+ if (typeof window.ensureCsrfToken === 'function') {
168
+ window.ensureCsrfToken(form);
169
+ }
170
+ form.submit();
171
+ }
172
+ window.closeKillSwitchModal(formId);
173
+ };
174
+
175
+ // Helper function to ensure CSRF token is included in form (globally available)
176
+ window.ensureCsrfToken = function(form) {
177
+ // Check if form already has a CSRF token
178
+ const existingToken = form.querySelector('input[name="authenticity_token"]');
179
+ if (existingToken) {
180
+ return; // Token already exists
181
+ }
182
+
183
+ // Get CSRF token from meta tag
184
+ const csrfMetaTag = document.querySelector('meta[name="csrf-token"]');
185
+ if (csrfMetaTag) {
186
+ const token = csrfMetaTag.getAttribute('content');
187
+ if (token) {
188
+ // Create hidden input with CSRF token
189
+ const tokenInput = document.createElement('input');
190
+ tokenInput.type = 'hidden';
191
+ tokenInput.name = 'authenticity_token';
192
+ tokenInput.value = token;
193
+ form.appendChild(tokenInput);
194
+ }
195
+ }
196
+ };
197
+
198
+ // Close modal when clicking outside of it (only add listener once)
199
+ if (!window._killSwitchModalListenersAttached) {
200
+ window.onclick = function(event) {
201
+ if (event.target.className === 'kill-switch-modal') {
202
+ event.target.style.display = 'none';
203
+ }
204
+ };
205
+
206
+ // Close modal on Escape key
207
+ document.addEventListener('keydown', function(event) {
208
+ if (event.key === 'Escape') {
209
+ const modals = document.querySelectorAll('.kill-switch-modal');
210
+ modals.forEach(modal => {
211
+ if (modal.style.display === 'block') {
212
+ modal.style.display = 'none';
213
+ }
214
+ });
215
+ }
216
+ });
217
+
218
+ window._killSwitchModalListenersAttached = true;
219
+ }
220
+ }
221
+ </script>
@@ -0,0 +1,40 @@
1
+ <%# Kill Switch Status Indicator %>
2
+ <%# Displays the current kill switch status and environment warning if applicable %>
3
+
4
+ <%
5
+ # Debug info (can be removed in production)
6
+ env = current_environment
7
+ is_active = kill_switch_active?
8
+ protected_envs = PgSqlTriggers.respond_to?(:kill_switch_environments) ? PgSqlTriggers.kill_switch_environments : %i[production staging]
9
+
10
+ # Log for debugging (remove in production)
11
+ Rails.logger.debug "[KILL_SWITCH_VIEW] is_active=#{is_active} env=#{env} protected_envs=#{Array(protected_envs).map(&:to_s).join(',')}"
12
+ %>
13
+
14
+ <% if is_active %>
15
+ <div class="kill-switch-status active" style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 12px; margin-bottom: 16px;">
16
+ <div style="display: flex; align-items: center; gap: 8px;">
17
+ <span style="font-size: 20px;">🛡️</span>
18
+ <div>
19
+ <strong style="color: #856404;">Kill Switch Active</strong>
20
+ <p style="margin: 4px 0 0 0; font-size: 14px; color: #856404;">
21
+ Dangerous operations in <strong><%= env %></strong> require confirmation.
22
+ All operations are logged and audited.
23
+ </p>
24
+ </div>
25
+ </div>
26
+ </div>
27
+ <% else %>
28
+ <div class="kill-switch-status inactive" style="background-color: #d1ecf1; border: 1px solid #bee5eb; border-radius: 4px; padding: 12px; margin-bottom: 16px;">
29
+ <div style="display: flex; align-items: center; gap: 8px;">
30
+ <span style="font-size: 20px;">ℹ️</span>
31
+ <div>
32
+ <strong style="color: #0c5460;">Kill Switch Inactive</strong>
33
+ <p style="margin: 4px 0 0 0; font-size: 14px; color: #0c5460;">
34
+ Kill switch is disabled in <strong><%= env %></strong>.
35
+ Operations can be performed without confirmation.
36
+ </p>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ <% end %>
@@ -101,5 +101,3 @@
101
101
  <%= link_to "Generate New Trigger", new_generator_path, class: "btn btn-primary" %>
102
102
  </div>
103
103
  <% end %>
104
-
105
-
@@ -63,14 +63,14 @@
63
63
  </p>
64
64
  </div>
65
65
  </div>
66
-
66
+
67
67
  <% if trigger.definition.present? %>
68
68
  <% definition = JSON.parse(trigger.definition) rescue {} %>
69
69
  <div style="margin-bottom: 1rem;">
70
70
  <strong>Function:</strong> <code style="background: #e9ecef; padding: 0.25rem 0.5rem; border-radius: 2px;"><%= definition["function_name"] || "N/A" %></code>
71
71
  <% if definition["events"].present? %>
72
72
  <span style="margin-left: 1rem;">
73
- <strong>Events:</strong>
73
+ <strong>Events:</strong>
74
74
  <% definition["events"].each do |event| %>
75
75
  <span class="badge badge-info" style="margin-left: 0.25rem;"><%= event %></span>
76
76
  <% end %>
@@ -78,7 +78,7 @@
78
78
  <% end %>
79
79
  </div>
80
80
  <% end %>
81
-
81
+
82
82
  <% if trigger.function_body.present? %>
83
83
  <details style="margin-top: 1rem;">
84
84
  <summary style="cursor: pointer; font-weight: 600; color: #495057;">View Function Body</summary>
@@ -123,4 +123,3 @@
123
123
  </div>
124
124
  </div>
125
125
  <% end %>
126
-
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ PgSqlTriggers.configure do |config|
4
+ # ========== Kill Switch Configuration ==========
5
+ # The Kill Switch is a safety mechanism that prevents accidental destructive operations
6
+ # in protected environments (production, staging, etc.)
7
+
8
+ # Enable or disable the kill switch globally
9
+ # Default: true (recommended for safety)
10
+ config.kill_switch_enabled = true
11
+
12
+ # Specify which environments should be protected by the kill switch
13
+ # Default: %i[production staging]
14
+ config.kill_switch_environments = %i[production staging]
15
+
16
+ # Require confirmation text for kill switch overrides
17
+ # When true, users must type a specific confirmation text to proceed
18
+ # Default: true (recommended for maximum safety)
19
+ config.kill_switch_confirmation_required = true
20
+
21
+ # Custom confirmation pattern generator
22
+ # Takes an operation symbol and returns the required confirmation text
23
+ # Default: "EXECUTE <OPERATION_NAME>"
24
+ config.kill_switch_confirmation_pattern = ->(operation) { "EXECUTE #{operation.to_s.upcase}" }
25
+
26
+ # Logger for kill switch events
27
+ # Default: Rails.logger
28
+ config.kill_switch_logger = Rails.logger
29
+
30
+ # Enable audit trail for kill switch events (optional enhancement)
31
+ # When enabled, all kill switch events are logged to a database table
32
+ # Default: false (can be enabled later)
33
+ # config.kill_switch_audit_trail_enabled = false
34
+
35
+ # Time-window auto-lock configuration (optional enhancement)
36
+ # Automatically enable kill switch during specific time windows
37
+ # Default: false
38
+ # config.kill_switch_auto_lock_enabled = false
39
+ # config.kill_switch_auto_lock_window = 30.minutes
40
+ # config.kill_switch_auto_lock_after = -> { Time.current.hour.between?(22, 6) } # Night hours
41
+
42
+ # Set the default environment detection
43
+ # By default, uses Rails.env
44
+ config.default_environment = -> { Rails.env }
45
+
46
+ # Set a custom permission checker
47
+ # This should return true/false based on the actor, action, and environment
48
+ # Example:
49
+ # config.permission_checker = ->(actor, action, environment) {
50
+ # # Your custom permission logic here
51
+ # # e.g., check if actor has required role for the action
52
+ # true
53
+ # }
54
+ config.permission_checker = nil
55
+
56
+ # Tables to exclude from listing in the UI
57
+ # Default excluded tables: ar_internal_metadata, schema_migrations, pg_sql_triggers_registry, trigger_migrations
58
+ # Add additional tables you want to exclude:
59
+ # config.excluded_tables = %w[audit_logs temporary_data]
60
+ config.excluded_tables = []
61
+
62
+ # ========== Migration Safety Configuration ==========
63
+ # Prevent unsafe DROP + CREATE operations in migrations
64
+ # When false (default), migrations with DROP + CREATE patterns will be blocked
65
+ # Set to true to allow unsafe operations (not recommended)
66
+ # You can also override per-migration with ALLOW_UNSAFE_MIGRATIONS=true environment variable
67
+ # Default: false (recommended for safety)
68
+ config.allow_unsafe_migrations = false
69
+ end