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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +160 -0
- data/SECURITY.md +15 -0
- data/app/assets/munawaba/INTER-LICENSE.txt +92 -0
- data/app/assets/munawaba/JETBRAINS-MONO-LICENSE.txt +93 -0
- data/app/assets/munawaba/dashboard.css +272 -0
- data/app/assets/munawaba/dashboard.js +60 -0
- data/app/assets/munawaba/inter.woff2 +0 -0
- data/app/assets/munawaba/jetbrains-mono.woff2 +0 -0
- data/app/assets/munawaba/logo.png +0 -0
- data/app/controllers/munawaba/application_controller.rb +149 -0
- data/app/controllers/munawaba/audit_events_controller.rb +31 -0
- data/app/controllers/munawaba/calendars_controller.rb +47 -0
- data/app/controllers/munawaba/frontends_controller.rb +28 -0
- data/app/controllers/munawaba/notification_deliveries_controller.rb +41 -0
- data/app/controllers/munawaba/overrides_controller.rb +52 -0
- data/app/controllers/munawaba/overviews_controller.rb +22 -0
- data/app/controllers/munawaba/people_controller.rb +73 -0
- data/app/controllers/munawaba/rotations_controller.rb +61 -0
- data/app/controllers/munawaba/schedules_controller.rb +115 -0
- data/app/controllers/munawaba/shifts_controller.rb +16 -0
- data/app/controllers/munawaba/slack_integrations_controller.rb +46 -0
- data/app/controllers/munawaba/themes_controller.rb +19 -0
- data/app/helpers/munawaba/application_helper.rb +208 -0
- data/app/jobs/munawaba/application_job.rb +7 -0
- data/app/jobs/munawaba/deliver_notification_job.rb +13 -0
- data/app/jobs/munawaba/dispatch_due_notifications_job.rb +7 -0
- data/app/jobs/munawaba/maintain_projection_job.rb +25 -0
- data/app/jobs/munawaba/maintenance_job.rb +28 -0
- data/app/models/munawaba/application_record.rb +7 -0
- data/app/models/munawaba/audit_event.rb +16 -0
- data/app/models/munawaba/notification_delivery.rb +75 -0
- data/app/models/munawaba/person.rb +23 -0
- data/app/models/munawaba/schedule.rb +85 -0
- data/app/models/munawaba/schedule_membership.rb +13 -0
- data/app/models/munawaba/shift.rb +51 -0
- data/app/models/munawaba/shift_override.rb +31 -0
- data/app/services/munawaba/audit/recorder.rb +17 -0
- data/app/services/munawaba/canonical.rb +43 -0
- data/app/services/munawaba/commands.rb +741 -0
- data/app/services/munawaba/conflicts/finder.rb +59 -0
- data/app/services/munawaba/integrations/planner_effects.rb +27 -0
- data/app/services/munawaba/integrations.rb +93 -0
- data/app/services/munawaba/notifications/context/v1.rb +65 -0
- data/app/services/munawaba/notifications/deliver.rb +94 -0
- data/app/services/munawaba/notifications/dispatcher.rb +50 -0
- data/app/services/munawaba/notifications/planner.rb +154 -0
- data/app/services/munawaba/notifications/reclaim_expired_leases.rb +27 -0
- data/app/services/munawaba/notifications/retry_failed.rb +51 -0
- data/app/services/munawaba/notifications/validity.rb +90 -0
- data/app/services/munawaba/notifications/wake_dispatcher.rb +17 -0
- data/app/services/munawaba/preview.rb +5 -0
- data/app/services/munawaba/result.rb +7 -0
- data/app/services/munawaba/shifts/normalize_future_timing.rb +172 -0
- data/app/services/munawaba/shifts/project.rb +50 -0
- data/app/services/munawaba/shifts/projection_plan.rb +89 -0
- data/app/services/munawaba/slack/client.rb +105 -0
- data/app/services/munawaba/slack/renderer.rb +48 -0
- data/app/services/munawaba/slack/webhook_validator.rb +35 -0
- data/app/services/munawaba/timing/boundary_calculator.rb +92 -0
- data/app/services/munawaba/timing/persisted_interval_verifier.rb +26 -0
- data/app/views/layouts/munawaba/application.html.erb +61 -0
- data/app/views/munawaba/audit_events/index.html.erb +85 -0
- data/app/views/munawaba/calendars/_shift.html.erb +17 -0
- data/app/views/munawaba/calendars/show.html.erb +81 -0
- data/app/views/munawaba/notification_deliveries/index.html.erb +65 -0
- data/app/views/munawaba/overrides/new.html.erb +19 -0
- data/app/views/munawaba/overviews/show.html.erb +76 -0
- data/app/views/munawaba/people/_form.html.erb +25 -0
- data/app/views/munawaba/people/edit.html.erb +2 -0
- data/app/views/munawaba/people/index.html.erb +49 -0
- data/app/views/munawaba/people/new.html.erb +2 -0
- data/app/views/munawaba/people/show.html.erb +34 -0
- data/app/views/munawaba/rotations/edit.html.erb +39 -0
- data/app/views/munawaba/schedules/_form.html.erb +49 -0
- data/app/views/munawaba/schedules/_resume_form.html.erb +24 -0
- data/app/views/munawaba/schedules/edit.html.erb +2 -0
- data/app/views/munawaba/schedules/index.html.erb +46 -0
- data/app/views/munawaba/schedules/new.html.erb +2 -0
- data/app/views/munawaba/schedules/resume.html.erb +4 -0
- data/app/views/munawaba/schedules/show.html.erb +86 -0
- data/app/views/munawaba/shared/_empty.html.erb +6 -0
- data/app/views/munawaba/shared/_errors.html.erb +23 -0
- data/app/views/munawaba/shared/_navigation.html.erb +8 -0
- data/app/views/munawaba/shared/_projection.html.erb +20 -0
- data/app/views/munawaba/shared/_proposal_details.html.erb +108 -0
- data/app/views/munawaba/shared/_shifts.html.erb +50 -0
- data/app/views/munawaba/shared/proposal.html.erb +5 -0
- data/app/views/munawaba/shifts/show.html.erb +65 -0
- data/app/views/munawaba/slack_integrations/edit.html.erb +48 -0
- data/config/routes.rb +45 -0
- data/db/migrate/20260905000001_create_munawaba_people.rb +33 -0
- data/db/migrate/20260905000002_create_munawaba_schedules.rb +73 -0
- data/db/migrate/20260905000003_create_munawaba_schedule_memberships.rb +25 -0
- data/db/migrate/20260905000004_create_munawaba_shifts.rb +51 -0
- data/db/migrate/20260905000005_create_munawaba_shift_overrides.rb +33 -0
- data/db/migrate/20260905000006_create_munawaba_notification_deliveries.rb +83 -0
- data/db/migrate/20260905000007_create_munawaba_audit_events.rb +36 -0
- data/db/migrate/20260905000008_simplify_munawaba_activity.rb +16 -0
- data/lib/generators/munawaba/install_generator.rb +34 -0
- data/lib/generators/munawaba/templates/munawaba.rb +23 -0
- data/lib/munawaba/configuration.rb +91 -0
- data/lib/munawaba/defaults.rb +20 -0
- data/lib/munawaba/engine.rb +16 -0
- data/lib/munawaba/version.rb +5 -0
- data/lib/munawaba.rb +33 -0
- 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,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
|