munawaba 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 (109) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +160 -0
  5. data/SECURITY.md +15 -0
  6. data/app/assets/munawaba/INTER-LICENSE.txt +92 -0
  7. data/app/assets/munawaba/JETBRAINS-MONO-LICENSE.txt +93 -0
  8. data/app/assets/munawaba/dashboard.css +272 -0
  9. data/app/assets/munawaba/dashboard.js +60 -0
  10. data/app/assets/munawaba/inter.woff2 +0 -0
  11. data/app/assets/munawaba/jetbrains-mono.woff2 +0 -0
  12. data/app/assets/munawaba/logo.png +0 -0
  13. data/app/controllers/munawaba/application_controller.rb +149 -0
  14. data/app/controllers/munawaba/audit_events_controller.rb +31 -0
  15. data/app/controllers/munawaba/calendars_controller.rb +47 -0
  16. data/app/controllers/munawaba/frontends_controller.rb +28 -0
  17. data/app/controllers/munawaba/notification_deliveries_controller.rb +41 -0
  18. data/app/controllers/munawaba/overrides_controller.rb +52 -0
  19. data/app/controllers/munawaba/overviews_controller.rb +22 -0
  20. data/app/controllers/munawaba/people_controller.rb +73 -0
  21. data/app/controllers/munawaba/rotations_controller.rb +61 -0
  22. data/app/controllers/munawaba/schedules_controller.rb +115 -0
  23. data/app/controllers/munawaba/shifts_controller.rb +16 -0
  24. data/app/controllers/munawaba/slack_integrations_controller.rb +46 -0
  25. data/app/controllers/munawaba/themes_controller.rb +19 -0
  26. data/app/helpers/munawaba/application_helper.rb +208 -0
  27. data/app/jobs/munawaba/application_job.rb +7 -0
  28. data/app/jobs/munawaba/deliver_notification_job.rb +13 -0
  29. data/app/jobs/munawaba/dispatch_due_notifications_job.rb +7 -0
  30. data/app/jobs/munawaba/maintain_projection_job.rb +25 -0
  31. data/app/jobs/munawaba/maintenance_job.rb +28 -0
  32. data/app/models/munawaba/application_record.rb +7 -0
  33. data/app/models/munawaba/audit_event.rb +16 -0
  34. data/app/models/munawaba/notification_delivery.rb +75 -0
  35. data/app/models/munawaba/person.rb +23 -0
  36. data/app/models/munawaba/schedule.rb +85 -0
  37. data/app/models/munawaba/schedule_membership.rb +13 -0
  38. data/app/models/munawaba/shift.rb +51 -0
  39. data/app/models/munawaba/shift_override.rb +31 -0
  40. data/app/services/munawaba/audit/recorder.rb +17 -0
  41. data/app/services/munawaba/canonical.rb +43 -0
  42. data/app/services/munawaba/commands.rb +741 -0
  43. data/app/services/munawaba/conflicts/finder.rb +59 -0
  44. data/app/services/munawaba/integrations/planner_effects.rb +27 -0
  45. data/app/services/munawaba/integrations.rb +93 -0
  46. data/app/services/munawaba/notifications/context/v1.rb +65 -0
  47. data/app/services/munawaba/notifications/deliver.rb +94 -0
  48. data/app/services/munawaba/notifications/dispatcher.rb +50 -0
  49. data/app/services/munawaba/notifications/planner.rb +154 -0
  50. data/app/services/munawaba/notifications/reclaim_expired_leases.rb +27 -0
  51. data/app/services/munawaba/notifications/retry_failed.rb +51 -0
  52. data/app/services/munawaba/notifications/validity.rb +90 -0
  53. data/app/services/munawaba/notifications/wake_dispatcher.rb +17 -0
  54. data/app/services/munawaba/preview.rb +5 -0
  55. data/app/services/munawaba/result.rb +7 -0
  56. data/app/services/munawaba/shifts/normalize_future_timing.rb +172 -0
  57. data/app/services/munawaba/shifts/project.rb +50 -0
  58. data/app/services/munawaba/shifts/projection_plan.rb +89 -0
  59. data/app/services/munawaba/slack/client.rb +105 -0
  60. data/app/services/munawaba/slack/renderer.rb +48 -0
  61. data/app/services/munawaba/slack/webhook_validator.rb +35 -0
  62. data/app/services/munawaba/timing/boundary_calculator.rb +92 -0
  63. data/app/services/munawaba/timing/persisted_interval_verifier.rb +26 -0
  64. data/app/views/layouts/munawaba/application.html.erb +61 -0
  65. data/app/views/munawaba/audit_events/index.html.erb +85 -0
  66. data/app/views/munawaba/calendars/_shift.html.erb +17 -0
  67. data/app/views/munawaba/calendars/show.html.erb +81 -0
  68. data/app/views/munawaba/notification_deliveries/index.html.erb +65 -0
  69. data/app/views/munawaba/overrides/new.html.erb +19 -0
  70. data/app/views/munawaba/overviews/show.html.erb +76 -0
  71. data/app/views/munawaba/people/_form.html.erb +25 -0
  72. data/app/views/munawaba/people/edit.html.erb +2 -0
  73. data/app/views/munawaba/people/index.html.erb +49 -0
  74. data/app/views/munawaba/people/new.html.erb +2 -0
  75. data/app/views/munawaba/people/show.html.erb +34 -0
  76. data/app/views/munawaba/rotations/edit.html.erb +39 -0
  77. data/app/views/munawaba/schedules/_form.html.erb +49 -0
  78. data/app/views/munawaba/schedules/_resume_form.html.erb +24 -0
  79. data/app/views/munawaba/schedules/edit.html.erb +2 -0
  80. data/app/views/munawaba/schedules/index.html.erb +46 -0
  81. data/app/views/munawaba/schedules/new.html.erb +2 -0
  82. data/app/views/munawaba/schedules/resume.html.erb +4 -0
  83. data/app/views/munawaba/schedules/show.html.erb +86 -0
  84. data/app/views/munawaba/shared/_empty.html.erb +6 -0
  85. data/app/views/munawaba/shared/_errors.html.erb +23 -0
  86. data/app/views/munawaba/shared/_navigation.html.erb +8 -0
  87. data/app/views/munawaba/shared/_projection.html.erb +20 -0
  88. data/app/views/munawaba/shared/_proposal_details.html.erb +108 -0
  89. data/app/views/munawaba/shared/_shifts.html.erb +50 -0
  90. data/app/views/munawaba/shared/proposal.html.erb +5 -0
  91. data/app/views/munawaba/shifts/show.html.erb +65 -0
  92. data/app/views/munawaba/slack_integrations/edit.html.erb +48 -0
  93. data/config/routes.rb +45 -0
  94. data/db/migrate/20260905000001_create_munawaba_people.rb +33 -0
  95. data/db/migrate/20260905000002_create_munawaba_schedules.rb +73 -0
  96. data/db/migrate/20260905000003_create_munawaba_schedule_memberships.rb +25 -0
  97. data/db/migrate/20260905000004_create_munawaba_shifts.rb +51 -0
  98. data/db/migrate/20260905000005_create_munawaba_shift_overrides.rb +33 -0
  99. data/db/migrate/20260905000006_create_munawaba_notification_deliveries.rb +83 -0
  100. data/db/migrate/20260905000007_create_munawaba_audit_events.rb +36 -0
  101. data/db/migrate/20260905000008_simplify_munawaba_activity.rb +16 -0
  102. data/lib/generators/munawaba/install_generator.rb +34 -0
  103. data/lib/generators/munawaba/templates/munawaba.rb +23 -0
  104. data/lib/munawaba/configuration.rb +91 -0
  105. data/lib/munawaba/defaults.rb +20 -0
  106. data/lib/munawaba/engine.rb +16 -0
  107. data/lib/munawaba/version.rb +5 -0
  108. data/lib/munawaba.rb +33 -0
  109. metadata +287 -0
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ # Run every minute, or repair the timing of one schedule after a delivery mismatch.
5
+ class MaintenanceJob < ApplicationJob
6
+ def perform(schedule_id = nil)
7
+ failure = nil
8
+ scope = Schedule.where(state: schedule_id ? %w[scheduled active pausing] : %w[scheduled pausing])
9
+ scope = scope.where(id: schedule_id) if schedule_id
10
+ scope.find_each(batch_size: 100) do |schedule|
11
+ Shifts::NormalizeFutureTiming.call(schedule: schedule)
12
+ rescue StandardError => error
13
+ failure ||= error
14
+ Munawaba.signal("maintenance_failed", task: "lifecycle", schedule_id: schedule.id)
15
+ end
16
+
17
+ unless schedule_id
18
+ [Notifications::ReclaimExpiredLeases, Notifications::Dispatcher].each do |task|
19
+ task.call
20
+ rescue StandardError => error
21
+ failure ||= error
22
+ Munawaba.signal("maintenance_failed", task: task.name)
23
+ end
24
+ end
25
+ raise failure if failure
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class AuditEvent < ApplicationRecord
5
+ self.table_name = "munawaba_audit_events"
6
+ belongs_to :schedule, class_name: "Munawaba::Schedule", optional: true
7
+ belongs_to :person, class_name: "Munawaba::Person", optional: true
8
+ belongs_to :shift, class_name: "Munawaba::Shift", optional: true
9
+ validates :event_type, presence: true, length: { maximum: 100 }
10
+ validate do
11
+ unless metadata.is_a?(Hash) && metadata.to_json.bytesize <= 131072
12
+ errors.add(:metadata, "must be an object no larger than 128 KiB")
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class NotificationDelivery < ApplicationRecord
5
+ self.table_name = "munawaba_notification_deliveries"
6
+ KINDS = %w[advance_reminder shift_start assignment_change next_assignment_change test].freeze
7
+ STATUSES = %w[pending enqueued processing delivered failed canceled stale].freeze
8
+ TERMINAL_STATUSES = %w[delivered failed canceled stale].freeze
9
+ CLEAR_CLAIM = { claim_token: nil, lease_expires_at: nil, enqueued_at: nil, processing_at: nil }.freeze
10
+ belongs_to :schedule, class_name: "Munawaba::Schedule"
11
+ belongs_to :shift, class_name: "Munawaba::Shift", optional: true
12
+ belongs_to :retry_of_delivery, class_name: "Munawaba::NotificationDelivery", optional: true
13
+ has_one :retry_delivery, class_name: "Munawaba::NotificationDelivery", foreign_key: :retry_of_delivery_id
14
+ validates :kind, inclusion: { in: KINDS }
15
+ validates :status, inclusion: { in: STATUSES }
16
+ validates :event_key, presence: true, length: { maximum: 255 }, uniqueness: true
17
+ validates :due_at, :expires_at, presence: true
18
+ validates :notification_revision, :attempt_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
19
+ validate :valid_context
20
+ validate {
21
+ errors.add(:base,
22
+ "Terminal deliveries cannot be reopened") if persisted? && TERMINAL_STATUSES.include?(status_in_database) && changed?
23
+ }
24
+ scope :unsent, -> { where(status: %w[pending enqueued]) }
25
+ scope :pending, -> { where(status: "pending") }
26
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
27
+ STATUSES.each { |value| define_method("#{value}?") { status == value } }
28
+
29
+ def mark_pending!(at:, error_code: last_error_code)
30
+ update_columns(**CLEAR_CLAIM, status: "pending", next_attempt_at: at, last_error_code: error_code,
31
+ updated_at: Time.current)
32
+ end
33
+
34
+ def finish!(status:, code: nil, now: Time.current, http_status: nil)
35
+ unknown = last_error_code == "delivery_outcome_unknown"
36
+ error = status.to_s == "delivered" ? nil : (unknown ? "delivery_outcome_unknown" : code)
37
+ update_columns(**CLEAR_CLAIM, status: status.to_s, next_attempt_at: nil, updated_at: now,
38
+ last_error_code: error, last_http_status: http_status || last_http_status,
39
+ delivered_at: status.to_s == "delivered" ? now : nil)
40
+ end
41
+
42
+ def retry_later!(now:, code:, retry_after: nil, unknown: false, validity: nil)
43
+ sticky = unknown || last_error_code == "delivery_outcome_unknown"
44
+ self.last_error_code = sticky ? "delivery_outcome_unknown" : code
45
+ validity ||= Notifications::Validity.call(self, now: now)
46
+ unless validity.valid?
47
+ finish!(status: validity.status, code: validity.code, now: now)
48
+ return
49
+ end
50
+ if attempt_count >= Defaults::NOTIFICATION_MAX_ATTEMPTS
51
+ finish!(status: :failed, code: "retry_exhausted", now: now)
52
+ return
53
+ end
54
+ window = [Defaults::NOTIFICATION_BACKOFF_CAP.to_f,
55
+ Defaults::NOTIFICATION_BACKOFF_BASE.to_f * (2**[attempt_count - 1, 0].max)].min
56
+ delay = 1 + (SecureRandom.random_number * [window - 1, 0].max)
57
+ delay += retry_after.to_f if retry_after
58
+ at = now + delay
59
+ if at >= expires_at
60
+ finish!(status: :stale, code: "expired_before_retry", now: now)
61
+ else
62
+ update_columns(last_http_status: last_http_status)
63
+ mark_pending!(at: at, error_code: last_error_code)
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def valid_context
70
+ Notifications::Context::V1.validate!(kind: kind, context: context)
71
+ rescue ArgumentError, Munawaba::Error => error
72
+ errors.add(:context, error.message)
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class Person < ApplicationRecord
5
+ self.table_name = "munawaba_people"
6
+ has_many :schedule_memberships, class_name: "Munawaba::ScheduleMembership", inverse_of: :person
7
+ has_many :schedules, through: :schedule_memberships
8
+ has_many :effective_shifts, class_name: "Munawaba::Shift", foreign_key: :effective_person_id
9
+ has_many :base_shifts, class_name: "Munawaba::Shift", foreign_key: :base_person_id
10
+ has_many :audit_events, class_name: "Munawaba::AuditEvent"
11
+
12
+ normalizes :name, with: ->(value) { value.strip }
13
+ normalizes :email, with: ->(value) { value.strip.downcase }
14
+ normalizes :slack_member_id, with: ->(value) { value.strip.presence }
15
+ validates :name, presence: true, length: { maximum: 120 }
16
+ validates :email, presence: true, length: { maximum: 320 }, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP }
17
+ validates :slack_member_id, allow_nil: true, uniqueness: true, format: { with: /\A[A-Z][A-Z0-9]{1,31}\z/ }
18
+ validates :active, inclusion: { in: [true, false] }
19
+ validates :lock_version, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
20
+ validate { errors.add(:deactivated_at, "must match active status") unless active == deactivated_at.nil? }
21
+ scope :active, -> { where(active: true) }
22
+ end
23
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class Schedule < ApplicationRecord
5
+ self.table_name = "munawaba_schedules"
6
+ STATES = %w[draft scheduled active pausing paused].freeze
7
+ CADENCES = %w[one_week two_weeks calendar_month].freeze
8
+ TIMING_FIELDS = %w[cadence time_zone anchor_local_date anchor_local_seconds].freeze
9
+ has_many :schedule_memberships, -> {
10
+ order(:position)
11
+ }, class_name: "Munawaba::ScheduleMembership", inverse_of: :schedule
12
+ has_many :people, through: :schedule_memberships
13
+ has_many :shifts, class_name: "Munawaba::Shift"
14
+ has_many :notification_deliveries, class_name: "Munawaba::NotificationDelivery"
15
+ has_many :audit_events, class_name: "Munawaba::AuditEvent"
16
+ encrypts :slack_webhook_url
17
+ self.filter_attributes += [:slack_webhook_url]
18
+ normalizes :name, with: ->(value) { value.strip }
19
+ validates :name, presence: true, length: { maximum: 120 }, uniqueness: { case_sensitive: false }
20
+ validates :state, inclusion: { in: STATES }
21
+ validates :cadence, inclusion: { in: CADENCES }
22
+ validates :time_zone, presence: true, length: { maximum: 100 }
23
+ validates :anchor_local_date, presence: true
24
+ validates :anchor_local_seconds,
25
+ numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 86340 }
26
+ validates :advance_notice_seconds,
27
+ numericality: { only_integer: true, greater_than_or_equal_to: 60, less_than_or_equal_to: 2592000 }
28
+ validates :coverage_revision, :rotation_revision, :lifecycle_revision, :notification_revision, :lock_version,
29
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
30
+ validate :valid_time_zone
31
+ validate {
32
+ errors.add(:anchor_local_date,
33
+ "must have a four-digit year (0001–9999)") if anchor_local_date && !(1..9999).cover?(anchor_local_date.year)
34
+ }
35
+ validate :minute_precision
36
+ validate :immutable_timing_after_commitment
37
+ validate :slack_configuration
38
+
39
+ def current_shift(at = Time.current)
40
+ shifts.live.current(at).first
41
+ end
42
+
43
+ def next_shift(at = Time.current)
44
+ shifts.live.where("starts_at > ?", at.utc).order(:starts_at, :id).first
45
+ end
46
+
47
+ def ordered_person_ids
48
+ schedule_memberships.reorder(:position).pluck(:person_id)
49
+ end
50
+
51
+ private
52
+
53
+ def valid_time_zone
54
+ TZInfo::Timezone.get(time_zone) if time_zone.present?
55
+ rescue TZInfo::InvalidTimezoneIdentifier
56
+ errors.add(:time_zone, "must be an IANA timezone")
57
+ end
58
+
59
+ def minute_precision
60
+ errors.add(:anchor_local_seconds,
61
+ "must have minute precision") if anchor_local_seconds && anchor_local_seconds % 60 != 0
62
+ end
63
+
64
+ def immutable_timing_after_commitment
65
+ return unless persisted? && (first_activated_at_in_database.present? || state_in_database == "scheduled")
66
+
67
+ errors.add(:base, "Timing can only change on a draft that has never started.") if (changed & TIMING_FIELDS).any?
68
+ end
69
+
70
+ def slack_configuration
71
+ if slack_webhook_url.present? != slack_webhook_configured_at.present?
72
+ errors.add(:slack_webhook_url, "configuration timestamp must match the webhook")
73
+ end
74
+ errors.add(:slack_enabled, "requires a webhook") if slack_enabled && slack_webhook_url.blank?
75
+ return if slack_webhook_url.blank? || !will_save_change_to_slack_webhook_url?
76
+
77
+ unless Slack::WebhookValidator.valid?(slack_webhook_url)
78
+ errors.add(:slack_webhook_url, "must be an allowed Slack incoming webhook")
79
+ end
80
+ unless ActiveRecord::Encryption.config.has_primary_key? && ActiveRecord::Encryption.config.has_key_derivation_salt?
81
+ errors.add(:slack_webhook_url, "requires host Active Record Encryption keys")
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class ScheduleMembership < ApplicationRecord
5
+ self.table_name = "munawaba_schedule_memberships"
6
+ belongs_to :schedule, class_name: "Munawaba::Schedule", inverse_of: :schedule_memberships
7
+ belongs_to :person, class_name: "Munawaba::Person", inverse_of: :schedule_memberships
8
+ validates :person_id, uniqueness: { scope: :schedule_id }
9
+ validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 },
10
+ uniqueness: { scope: :schedule_id }
11
+ validate { errors.add(:person, "must be active") if new_record? && person && !person.active? }
12
+ end
13
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class Shift < ApplicationRecord
5
+ self.table_name = "munawaba_shifts"
6
+ belongs_to :schedule, class_name: "Munawaba::Schedule"
7
+ belongs_to :base_person, class_name: "Munawaba::Person"
8
+ belongs_to :effective_person, class_name: "Munawaba::Person"
9
+ has_many :shift_overrides, class_name: "Munawaba::ShiftOverride"
10
+ has_one :active_override, -> { where(ended_at: nil) }, class_name: "Munawaba::ShiftOverride"
11
+ has_many :notification_deliveries, class_name: "Munawaba::NotificationDelivery"
12
+ validates :starts_at, :ends_at, :generated_at, presence: true
13
+ validates :coverage_revision, :assignment_version, :timing_version,
14
+ numericality: { only_integer: true, greater_than: 0 }
15
+ validates :boundary_index, :rotation_revision, :lock_version,
16
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
17
+ validates :cancellation_reason, inclusion: { in: %w[pause scheduled_run_canceled] }, allow_nil: true
18
+ validate :interval_and_cancellation
19
+ validate { errors.add(:base, "Canceled shifts are terminal") if persisted? && canceled_at_in_database && changed? }
20
+ scope :live, -> { where(canceled_at: nil) }
21
+ scope :current, ->(at = Time.current) { where("starts_at <= ? AND ends_at > ?", at.utc, at.utc) }
22
+ scope :overlapping, ->(from, to) {
23
+ where("tstzrange(starts_at, ends_at, '[)') && tstzrange(?, ?, '[)')", from.utc, to.utc)
24
+ }
25
+ def canceled? = canceled_at.present?
26
+
27
+ def self.persist_projection!(schedule:, slots:, now:)
28
+ connection.execute("SET CONSTRAINTS ALL DEFERRED")
29
+ rows = where(id: slots.filter_map { |slot| slot[:id] }).index_by(&:id)
30
+ slots.map do |slot|
31
+ values = slot.slice(:coverage_revision, :boundary_index, :starts_at, :ends_at, :base_person_id, :effective_person_id,
32
+ :rotation_revision, :assignment_version, :timing_version)
33
+ if slot[:id]
34
+ row = rows.fetch(slot[:id])
35
+ row.update!(values) if values.any? { |key, value| row.public_send(key) != value }
36
+ row
37
+ else
38
+ create!(values.merge(schedule_id: schedule.id, generated_at: now))
39
+ end
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def interval_and_cancellation
46
+ errors.add(:ends_at, "must be after the start") if starts_at && ends_at && ends_at <= starts_at
47
+ errors.add(:cancellation_reason,
48
+ "must match the cancellation time") unless canceled_at.nil? == cancellation_reason.nil?
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class ShiftOverride < ApplicationRecord
5
+ self.table_name = "munawaba_shift_overrides"
6
+ END_REASONS = %w[revoked superseded restored_to_base shift_canceled].freeze
7
+ belongs_to :shift, class_name: "Munawaba::Shift"
8
+ belongs_to :previous_person, class_name: "Munawaba::Person"
9
+ belongs_to :replacement_person, class_name: "Munawaba::Person"
10
+ normalizes :reason, with: ->(value) { value.strip.presence }
11
+ validates :reason, length: { maximum: 1000 }
12
+ validates :end_reason, inclusion: { in: END_REASONS }, allow_nil: true
13
+ validate :valid_transition
14
+
15
+ private
16
+
17
+ def valid_transition
18
+ errors.add(:replacement_person,
19
+ "must differ from the previous person") if previous_person_id == replacement_person_id
20
+ errors.add(:replacement_person,
21
+ "must be active") if new_record? && replacement_person && !replacement_person.active?
22
+ errors.add(:end_reason, "must match the end time") unless ended_at.nil? == end_reason.nil?
23
+ errors.add(:ended_at, "cannot precede creation") if ended_at && created_at && ended_at < created_at
24
+ return unless persisted?
25
+
26
+ errors.add(:base, "Ended overrides are terminal") if ended_at_in_database && changed?
27
+ errors.add(:base, "Only an override terminal transition may be updated") if (changed - %w[ended_at end_reason
28
+ updated_at]).any?
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Audit
5
+ # Metadata is stored without filtering; omit secrets and request/model snapshots.
6
+ class Recorder
7
+ def self.record!(event_type:, metadata: {}, actor: nil, operation_id: SecureRandom.uuid,
8
+ occurred_at: Time.current, schedule: nil, person: nil, shift: nil, shift_id: nil)
9
+ snapshot = actor.to_h.symbolize_keys.slice(:type, :id, :name).transform_values { |value| value&.to_s.presence }
10
+ AuditEvent.create!(event_type: event_type, operation_id: operation_id, occurred_at: occurred_at,
11
+ schedule: schedule, person: person, shift_id: shift&.id || shift_id,
12
+ actor_type: snapshot[:type], actor_id: snapshot[:id], actor_name: snapshot[:name],
13
+ metadata: metadata.as_json)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Munawaba
7
+ module Canonical
8
+ module_function
9
+
10
+ def time(value)
11
+ Time.at(value.to_time.utc.to_r.floor(6)).utc
12
+ end
13
+
14
+ def micros(value)
15
+ (value.to_time.utc.to_r * 1_000_000).floor
16
+ end
17
+
18
+ def instant(value)
19
+ value&.utc&.iso8601(6)
20
+ end
21
+
22
+ def from_micros(value)
23
+ Time.at(Rational(Integer(value), 1_000_000)).utc
24
+ end
25
+
26
+ def digest(value)
27
+ Digest::SHA256.hexdigest(JSON.generate(value))
28
+ end
29
+
30
+ def equal?(left, right)
31
+ left.is_a?(String) && right.is_a?(String) && left.bytesize == right.bytesize &&
32
+ ActiveSupport::SecurityUtils.secure_compare(left, right)
33
+ end
34
+
35
+ def deep_freeze(value)
36
+ case value
37
+ when Array then value.each { |item| deep_freeze(item) }
38
+ when Hash then value.each { |key, item| deep_freeze(key); deep_freeze(item) }
39
+ end
40
+ value.freeze
41
+ end
42
+ end
43
+ end