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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Notifications
5
+ class Validity
6
+ Result = Struct.new(:status, :code, :schedule, :shift, keyword_init: true) do
7
+ def valid? = status.nil?
8
+ end
9
+ KIND_SETTINGS = { "advance_reminder" => :notify_advance, "shift_start" => :notify_shift_start,
10
+ "assignment_change" => :notify_assignment_change, "next_assignment_change" => :notify_next_assignment_change }.freeze
11
+
12
+ def self.call(delivery, now: Time.current, allow_old_notification_revision: false)
13
+ # Bypass earlier cached reads so sending and lease recovery see committed changes.
14
+ ActiveRecord::Base.uncached do
15
+ validate(delivery, now: now, allow_old_notification_revision: allow_old_notification_revision)
16
+ end
17
+ end
18
+
19
+ def self.validate(delivery, now:, allow_old_notification_revision:)
20
+ context = Context::V1.validate!(kind: delivery.kind, context: delivery.context)
21
+ schedule = Schedule.find(delivery.schedule_id)
22
+ result = Result.new(schedule: schedule)
23
+ reject = ->(status, code) { result.status = status; result.code = code; result }
24
+ return reject.call(:stale, "expired") if delivery.expires_at <= now
25
+ return reject.call(:canceled,
26
+ "integration_disabled") unless schedule.slack_enabled && schedule.slack_webhook_configured_at
27
+ unless allow_old_notification_revision || delivery.notification_revision == schedule.notification_revision
28
+ return reject.call(:stale, "notification_changed")
29
+ end
30
+
31
+ setting = KIND_SETTINGS[delivery.kind]
32
+ return reject.call(:canceled, "kind_disabled") if setting && !schedule.public_send(setting)
33
+ return result if delivery.kind == "test"
34
+ return reject.call(:stale, "coverage_changed") unless delivery.coverage_revision == schedule.coverage_revision
35
+ return reject.call(:canceled, "schedule_inactive") unless %w[scheduled active pausing].include?(schedule.state)
36
+
37
+ shift = Shift.find(delivery.shift_id || context.fetch("described_shift_id"))
38
+ result.shift = shift
39
+ return reject.call(:canceled, "shift_canceled") if shift.canceled_at
40
+ return reject.call(:stale,
41
+ "shift_changed") unless shift.schedule_id == schedule.id && shift.coverage_revision == schedule.coverage_revision
42
+ return reject.call(:stale, "timing_changed") unless shift.timing_version == delivery.timing_version
43
+
44
+ if delivery.kind == "next_assignment_change"
45
+ return reject.call(:stale, "rotation_changed") unless schedule.rotation_revision == delivery.rotation_revision
46
+ raise Context::V1::Invalid unless shift.base_person_id == context["new_next_person_id"] && shift.id == context["described_shift_id"]
47
+
48
+ Person.find(context.fetch("previous_next_person_id"))
49
+ Person.find(context.fetch("new_next_person_id"))
50
+ else
51
+ return reject.call(:stale,
52
+ "assignment_changed") unless delivery.assignment_version == shift.assignment_version
53
+
54
+ validate_transition!(context, shift) if delivery.kind == "assignment_change"
55
+ end
56
+ unless Timing::PersistedIntervalVerifier.valid?(shift: shift, now: now)
57
+ return reject.call(:stale, "timing_rules_changed")
58
+ end
59
+
60
+ result
61
+ rescue Context::V1::Invalid, ActiveRecord::RecordNotFound, KeyError
62
+ Result.new(status: :stale, code: "invalid_delivery_context")
63
+ end
64
+
65
+ def self.validate_transition!(context, shift)
66
+ previous = Person.find(context.fetch("previous_person_id"))
67
+ current = Person.find(context.fetch("new_person_id"))
68
+ raise Context::V1::Invalid unless current.id == shift.effective_person_id
69
+
70
+ replacement = ShiftOverride.find(context["override_id"]) if context["override_id"]
71
+ ended = ShiftOverride.find(context["ended_override_id"]) if context["ended_override_id"]
72
+ if replacement
73
+ raise Context::V1::Invalid unless replacement.shift_id == shift.id && replacement.ended_at.nil? && replacement.previous_person_id == previous.id && replacement.replacement_person_id == current.id
74
+ end
75
+ if ended
76
+ raise Context::V1::Invalid unless ended.shift_id == shift.id && ended.ended_at && ended.replacement_person_id == previous.id
77
+
78
+ expected_reason = { "override_superseded" => "superseded", "override_revoked" => "revoked",
79
+ "override_restored_to_base" => "restored_to_base" }.fetch(context["transition_type"])
80
+ raise Context::V1::Invalid unless ended.end_reason == expected_reason
81
+ end
82
+ if !replacement && shift.base_person_id != current.id
83
+ raise Context::V1::Invalid
84
+ end
85
+
86
+ true
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Notifications
5
+ class WakeDispatcher
6
+ def self.call
7
+ ActiveRecord.after_all_transactions_commit do
8
+ begin
9
+ DispatchDueNotificationsJob.perform_later
10
+ rescue StandardError
11
+ Munawaba.signal("dispatcher_wake_failed")
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ Preview = Struct.new(:token, :projection, :conflicts, :details, keyword_init: true)
5
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ Result = Struct.new(:status, :record, :errors, :preview, keyword_init: true) do
5
+ def success? = status < 400
6
+ end
7
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Shifts
5
+ class NormalizeFutureTiming
6
+ SCHEDULE_FIELDS = %w[state coverage_starts_at first_activated_at lifecycle_revision coverage_start_boundary
7
+ generated_through_boundary rotation_effective_boundary pause_effective_at].freeze
8
+ Prepared = Struct.new(:schedule, :virtual_schedule, :rows, :slots, :changes,
9
+ :clipping, :conflicts, :audit_before, :finalized, keyword_init: true) do
10
+ def changed? = changes.any? || SCHEDULE_FIELDS.any? { |key| schedule[key] != virtual_schedule[key] }
11
+ end
12
+ SLOT_FIELDS = %i[id schedule_id coverage_revision boundary_index starts_at ends_at base_person_id
13
+ effective_person_id rotation_revision assignment_version timing_version].freeze
14
+
15
+ def self.call(schedule:, now: nil)
16
+ ActiveRecord::Base.transaction(requires_new: true) do
17
+ schedule.lock!("FOR NO KEY UPDATE")
18
+ captured = Canonical.time(now || Time.current)
19
+ prepared = locked_preparation(schedule, captured)
20
+ apply!(prepared, now: captured)
21
+ prepared
22
+ end
23
+ end
24
+
25
+ def self.locked_preparation(schedule, now)
26
+ live = Shift.where(schedule_id: schedule.id, coverage_revision: schedule.coverage_revision, canceled_at: nil)
27
+ # The latest started shift fixes the boundary with coverage already underway.
28
+ ids = case schedule.state
29
+ when "scheduled", "active"
30
+ future_ids = live.where("starts_at > ?", now).pluck(:id)
31
+ if schedule.state == "active" || schedule.coverage_starts_at <= now
32
+ preceding_id = live.where("starts_at <= ?", now).order(starts_at: :desc, id: :desc).pick(:id)
33
+ end
34
+ first_id = live.where(boundary_index: schedule.coverage_start_boundary).pick(:id) if schedule.state == "scheduled"
35
+ future_ids + [preceding_id, first_id].compact
36
+ when "pausing"
37
+ live.where(boundary_index: schedule.generated_through_boundary).pluck(:id)
38
+ else []
39
+ end
40
+ shifts = live.where(id: ids.uniq).order(:id).lock("FOR NO KEY UPDATE").to_a
41
+ ShiftOverride.where(shift_id: shifts.map(&:id), ended_at: nil).order(:id).lock("FOR NO KEY UPDATE").load
42
+ NotificationDelivery.where(schedule_id: schedule.id,
43
+ status: %w[pending enqueued
44
+ processing]).order(:id).lock("FOR NO KEY UPDATE").load
45
+ prepare(schedule: schedule, now: now, shifts: shifts)
46
+ end
47
+
48
+ def self.slot(shift)
49
+ SLOT_FIELDS.to_h { |key| [key, shift.public_send(key)] }
50
+ end
51
+
52
+ def self.prepare(schedule:, now:, shifts: nil, provider: Timing::BoundaryCalculator.method(:timezone))
53
+ rows = shifts || Shift.where(schedule_id: schedule.id, coverage_revision: schedule.coverage_revision,
54
+ canceled_at: nil).order(:id).to_a
55
+ rows = rows.select { |row|
56
+ row.canceled_at.nil? && row.coverage_revision == schedule.coverage_revision
57
+ }.sort_by(&:boundary_index)
58
+ virtual = schedule.dup
59
+ virtual.id = schedule.id
60
+ slots = rows.map { |row| slot(row) }
61
+ changes = []
62
+ clipping = false
63
+ if %w[scheduled active].include?(schedule.state) && rows.any?
64
+ calculator = Timing::BoundaryCalculator.new(schedule, provider: provider)
65
+ first = rows.first
66
+ scheduled = schedule.state == "scheduled"
67
+ first_new_start = calculator.boundary(first.boundary_index).resolved_at
68
+ # If a changed rule moves the start into the past, begin at now instead
69
+ # of creating coverage for time that has already passed.
70
+ scheduled_moved = scheduled && first_new_start != schedule.coverage_starts_at
71
+ clipping = scheduled_moved && first_new_start <= now
72
+ slots.each_with_index do |item, index|
73
+ next unless item[:starts_at] > now || scheduled_moved
74
+
75
+ left = calculator.boundary(item[:boundary_index]).resolved_at
76
+ right = calculator.boundary(item[:boundary_index] + 1).resolved_at
77
+ prior = index.positive? && slots[index - 1]
78
+ left = prior[:ends_at] if prior && (prior[:starts_at] <= now || scheduled_moved)
79
+ left = now if clipping && index.zero?
80
+ raise ArgumentError, "Timing maintenance is required: an interval is no longer positive" unless right > left
81
+
82
+ if item[:starts_at] != left || item[:ends_at] != right
83
+ item[:starts_at] = left
84
+ item[:ends_at] = right
85
+ item[:timing_version] += 1
86
+ changes << item
87
+ end
88
+ end
89
+ if scheduled
90
+ start = slots.first[:starts_at]
91
+ moved = start != schedule.coverage_starts_at
92
+ virtual.coverage_starts_at = start
93
+ if start <= now
94
+ virtual.state = "active"
95
+ virtual.first_activated_at ||= start
96
+ end
97
+ virtual.lifecycle_revision += 1 if moved || virtual.state != schedule.state
98
+ end
99
+ elsif schedule.state == "pausing" && schedule.pause_effective_at <= now
100
+ virtual.assign_attributes(state: "paused", coverage_start_boundary: nil, coverage_starts_at: nil,
101
+ generated_through_boundary: nil, rotation_effective_boundary: nil, pause_effective_at: nil,
102
+ lifecycle_revision: schedule.lifecycle_revision + 1)
103
+ end
104
+ slots.each_cons(2) do |left, right|
105
+ raise ArgumentError,
106
+ "Timing maintenance would overlap retained intervals" if left[:ends_at] > right[:starts_at]
107
+ end
108
+ Prepared.new(schedule: schedule, virtual_schedule: virtual, rows: rows, slots: slots,
109
+ changes: changes, clipping: clipping)
110
+ end
111
+
112
+ # Keep preparation separate so confirmations can verify the signed preview
113
+ # and its conflicts before changing stored shifts.
114
+ def self.apply!(prepared, now:, operation_id: SecureRandom.uuid, defer: false)
115
+ schedule = prepared.schedule
116
+ prepared.audit_before = { start: schedule.coverage_starts_at, state: schedule.state,
117
+ pause: schedule.pause_effective_at, first_ever: schedule.first_activated_at.nil? }
118
+ Shift.connection.execute("SET CONSTRAINTS ALL DEFERRED") if prepared.changes.any?
119
+ prepared.changes.each do |slot|
120
+ prepared.rows.find { |row|
121
+ row.id == slot[:id]
122
+ }.update!(starts_at: slot[:starts_at], ends_at: slot[:ends_at], timing_version: slot[:timing_version])
123
+ end
124
+ changes = SCHEDULE_FIELDS.select { |key| prepared.schedule[key] != prepared.virtual_schedule[key] }
125
+ prepared.schedule.update!(prepared.virtual_schedule.attributes.slice(*changes)) if changes.any?
126
+ finalize!(prepared, now: now, operation_id: operation_id) unless defer
127
+ prepared
128
+ end
129
+
130
+ # Combined changes publish timing effects after the final assignments are saved.
131
+ def self.finalize!(prepared, now:, operation_id:)
132
+ return if prepared.finalized
133
+
134
+ prepared.finalized = true
135
+ schedule = prepared.schedule
136
+ before_start, before_state, before_pause, first_ever = prepared.audit_before.values_at(:start, :state, :pause, :first_ever)
137
+ audit_attributes = { schedule: schedule, operation_id: operation_id, occurred_at: now }
138
+
139
+ if prepared.changes.any?
140
+ conflicts = prepared.conflicts || Conflicts::Finder.call(slots: prepared.changes)
141
+ rows = Shift.where(id: prepared.changes.map { |slot| slot[:id] }).order(:id).to_a
142
+ Notifications::Planner.call(schedule: schedule, now: now, operation: "timing", shifts: rows,
143
+ invalidation_shift_ids: rows.map(&:id))
144
+ if conflicts.any?
145
+ ActiveSupport::Notifications.instrument("timing.conflicts.munawaba", schedule_id: schedule.id,
146
+ conflict_count: conflicts.length)
147
+ end
148
+ Audit::Recorder.record!(**audit_attributes, event_type: "timing.future_projection_recomputed", metadata: {
149
+ changed_count: rows.length,
150
+ coverage_starts_at_before: Canonical.instant(before_start),
151
+ coverage_starts_at_after: Canonical.instant(schedule.coverage_starts_at),
152
+ state_before: before_state, state_after: schedule.state
153
+ })
154
+ end
155
+
156
+ if before_state == "scheduled" && schedule.state == "active"
157
+ first = prepared.rows.min_by(&:boundary_index)
158
+ event = first_ever ? "schedule.activated" : "schedule.resumed"
159
+ Audit::Recorder.record!(**audit_attributes, event_type: event, shift: first, metadata: {
160
+ state_before: before_state, state_after: schedule.state,
161
+ coverage_starts_at: schedule.coverage_starts_at, next_person_id: first.base_person_id
162
+ })
163
+ elsif before_state == "pausing" && schedule.state == "paused"
164
+ last = prepared.rows.max_by(&:boundary_index)
165
+ Audit::Recorder.record!(**audit_attributes, event_type: "schedule.paused", shift: last, metadata: {
166
+ state_before: before_state, state_after: schedule.state, effective_at: before_pause
167
+ })
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Shifts
5
+ class Project
6
+ def self.call(schedule:, observed_revision: nil, now: nil)
7
+ operation_id = SecureRandom.uuid
8
+ ActiveRecord::Base.transaction(requires_new: true) do
9
+ schedule.lock!("FOR NO KEY UPDATE")
10
+ captured = now ? Canonical.time(now) : Canonical.time(Time.current)
11
+ next [] unless %w[scheduled active].include?(schedule.state)
12
+ next [] if observed_revision && schedule.coverage_revision != observed_revision
13
+
14
+ prepared = NormalizeFutureTiming.locked_preparation(schedule, captured)
15
+ NormalizeFutureTiming.apply!(prepared, now: captured, operation_id: operation_id)
16
+ next [] unless %w[scheduled active].include?(schedule.state)
17
+
18
+ calculator = Timing::BoundaryCalculator.new(schedule)
19
+ target = ProjectionPlan.target(calculator, schedule.coverage_start_boundary,
20
+ captured + Defaults::SHIFT_GENERATION_HORIZON)
21
+ cursor = schedule.generated_through_boundary
22
+ next [] if cursor >= target
23
+
24
+ ids = schedule.ordered_person_ids
25
+ raise ArgumentError,
26
+ "A live schedule requires active rotation members" if ids.empty? || Person.active.where(id: ids).count != ids.length
27
+
28
+ last = prepared.slots.max_by { |slot| slot[:boundary_index] }
29
+ slots = ((cursor + 1)..target).map do |index|
30
+ person_id = ids[(index - schedule.rotation_effective_boundary) % ids.length]
31
+ start = index == cursor + 1 && last ? last[:ends_at] : calculator.boundary(index).resolved_at
32
+ { id: nil, schedule_id: schedule.id, coverage_revision: schedule.coverage_revision, boundary_index: index,
33
+ starts_at: start, ends_at: calculator.boundary(index + 1).resolved_at,
34
+ base_person_id: person_id, effective_person_id: person_id, rotation_revision: schedule.rotation_revision, assignment_version: 1, timing_version: 1 }
35
+ end
36
+ conflicts = Conflicts::Finder.call(slots: slots)
37
+ rows = Shift.persist_projection!(schedule: schedule, slots: slots, now: captured)
38
+ schedule.update!(generated_through_boundary: target)
39
+ Notifications::Planner.call(schedule: schedule, now: captured, operation: "project", shifts: rows)
40
+ Audit::Recorder.record!(event_type: "timing.projection_extended", schedule: schedule,
41
+ operation_id: operation_id, occurred_at: captured,
42
+ metadata: { inserted_count: rows.length, projected_until: rows.last.ends_at })
43
+ ActiveSupport::Notifications.instrument("projection.conflicts.munawaba", schedule_id: schedule.id,
44
+ conflict_count: conflicts.length) if conflicts.any?
45
+ rows
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Shifts
5
+ class ProjectionPlan
6
+ MAX_BIGINT = 9_223_372_036_854_775_807
7
+ attr_reader :state, :coverage_revision, :rotation_revision, :first_boundary, :target_boundary,
8
+ :boundaries, :slots, :canonical
9
+
10
+ def initialize(schedule:, operation_kind:, now:, person_ids:, first_boundary:, target_boundary: nil,
11
+ conflict_check_at: nil, provider: Timing::BoundaryCalculator.method(:timezone))
12
+ @schedule_id = schedule.id
13
+ @operation_kind = operation_kind.to_s
14
+ @first_boundary = Integer(first_boundary)
15
+ @coverage_revision = schedule.coverage_revision + 1
16
+ @rotation_revision = schedule.rotation_revision + (@operation_kind == "resume" ? 1 : 0)
17
+ @conflict_check_at = conflict_check_at && Canonical.time(conflict_check_at)
18
+ @calculator = Timing::BoundaryCalculator.new(schedule, provider: provider)
19
+ @state = @operation_kind == "immediate_activation" || @calculator.boundary(@first_boundary).resolved_at == now ? "active" : "scheduled"
20
+ @target_boundary = target_boundary.nil? ? self.class.target(@calculator, @first_boundary,
21
+ now + Defaults::SHIFT_GENERATION_HORIZON) : Integer(target_boundary)
22
+ validate!(person_ids)
23
+ @boundaries = (@first_boundary..(@target_boundary + 1)).map { |index| @calculator.boundary(index) }.freeze
24
+ @slots = (@first_boundary..@target_boundary).map do |index|
25
+ left, right = @boundaries[index - @first_boundary, 2]
26
+ immediate = index == @first_boundary && @operation_kind == "immediate_activation"
27
+ base = person_ids[(index - @first_boundary) % person_ids.length]
28
+ { schedule_id: @schedule_id, id: nil, coverage_revision: @coverage_revision, boundary_index: index,
29
+ starts_at: immediate ? @conflict_check_at : left.resolved_at, ends_at: right.resolved_at,
30
+ base_person_id: base, effective_person_id: base, rotation_revision: @rotation_revision,
31
+ assignment_version: 1, timing_version: 1, start_resolution: immediate ? nil : left.resolution,
32
+ start_adjustment_seconds: immediate ? nil : left.adjustment_seconds,
33
+ end_resolution: right.resolution, end_adjustment_seconds: right.adjustment_seconds,
34
+ start_basis: immediate ? "activation_conflict_check" : "canonical" }
35
+ end
36
+ @slots.each { |slot|
37
+ raise ArgumentError, "Invalid projected interval" unless slot[:ends_at] > slot[:starts_at]
38
+ }
39
+ Canonical.deep_freeze(@slots)
40
+ @canonical = [1, @operation_kind, @schedule_id, @state, @coverage_revision, @rotation_revision,
41
+ @first_boundary, @target_boundary, @operation_kind == "immediate_activation" ? "confirmation_now_subset" : "canonical",
42
+ @conflict_check_at && Canonical.micros(@conflict_check_at),
43
+ [schedule.cadence, schedule.time_zone, schedule.anchor_local_date.iso8601, schedule.anchor_local_seconds],
44
+ @boundaries.map { |b|
45
+ [b.index, b.nominal_local_date.iso8601, b.nominal_local_seconds, Canonical.micros(b.resolved_at), b.resolution, b.adjustment_seconds, b.utc_offset_seconds]
46
+ },
47
+ @slots.map { |s|
48
+ [s[:boundary_index], Canonical.micros(s[:starts_at]), Canonical.micros(s[:ends_at]), s[:base_person_id], s[:effective_person_id], s[:start_resolution], s[:start_adjustment_seconds], s[:end_resolution], s[:end_adjustment_seconds], s[:rotation_revision], s[:assignment_version], s[:timing_version], s[:start_basis]]
49
+ }]
50
+ Canonical.deep_freeze(@canonical)
51
+ freeze
52
+ end
53
+
54
+ def digest = Canonical.digest(@canonical)
55
+
56
+ def persistence_slots(now:)
57
+ return @slots unless @operation_kind == "immediate_activation"
58
+ raise ArgumentError,
59
+ "Immediate activation left its acknowledged slot" unless @conflict_check_at <= now && now < @slots.first[:ends_at]
60
+
61
+ Canonical.deep_freeze(@slots.map.with_index { |slot, index| index.zero? ? slot.merge(starts_at: now) : slot })
62
+ end
63
+
64
+ def self.target(calculator, first_boundary, cutoff)
65
+ cutoff = [cutoff, calculator.boundary(first_boundary + 1).resolved_at].max
66
+ [first_boundary, calculator.slot_at(cutoff)].max
67
+ end
68
+
69
+ private
70
+
71
+ def validate!(person_ids)
72
+ valid = %w[immediate_activation future_activation resume].include?(@operation_kind) &&
73
+ @schedule_id.is_a?(Integer) && @schedule_id.between?(1, MAX_BIGINT) &&
74
+ @coverage_revision.between?(1, MAX_BIGINT) && @rotation_revision.between?(0, MAX_BIGINT) &&
75
+ @first_boundary.between?(0,
76
+ MAX_BIGINT - 1) && @target_boundary.between?(@first_boundary,
77
+ MAX_BIGINT - 1) &&
78
+ person_ids.present? && person_ids.uniq == person_ids && person_ids.all? { |id|
79
+ id.is_a?(Integer) && id.between?(1,
80
+ MAX_BIGINT)
81
+ }
82
+ valid &&= @operation_kind == "immediate_activation" ? !@conflict_check_at.nil? : @conflict_check_at.nil?
83
+ valid &&= @first_boundary.zero? if @operation_kind == "future_activation"
84
+ valid &&= @calculator.boundary(@first_boundary).resolved_at <= @conflict_check_at if @operation_kind == "immediate_activation"
85
+ raise ArgumentError, "Invalid projection plan" unless valid
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "timeout"
6
+
7
+ module Munawaba
8
+ module Slack
9
+ class Client
10
+ Result = Struct.new(:status, :http_status, :error_code, :retry_after, :unknown, keyword_init: true)
11
+ class DeadlineExceeded < StandardError; end
12
+ class ResponseTooLarge < StandardError; end
13
+ MAX_RESPONSE_BYTES = 16_384
14
+
15
+ def self.monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
16
+
17
+ def self.call(webhook:, payload:, deadline:)
18
+ new.call(webhook: webhook, payload: payload, deadline: deadline)
19
+ end
20
+
21
+ def call(webhook:, payload:, deadline:)
22
+ transmitted = false
23
+ http = nil
24
+ uri = WebhookValidator.validate!(webhook)
25
+ remaining = deadline - self.class.monotonic
26
+ raise DeadlineExceeded if remaining <= 0
27
+
28
+ http = Net::HTTP.new(uri.host, uri.port, nil)
29
+ http.use_ssl = true
30
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
31
+ http.max_retries = 0
32
+ http.open_timeout = [Defaults::SLACK_OPEN_TIMEOUT.to_f, remaining].min
33
+ http.read_timeout = [Defaults::SLACK_READ_TIMEOUT.to_f, remaining].min
34
+ http.write_timeout = [Defaults::SLACK_WRITE_TIMEOUT.to_f, remaining].min
35
+ request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
36
+ request.body = JSON.generate(payload)
37
+ result = nil
38
+ Timeout.timeout(remaining, DeadlineExceeded) do
39
+ http.start do
40
+ budget = deadline - self.class.monotonic
41
+ raise DeadlineExceeded if budget <= 0
42
+
43
+ http.read_timeout = [Defaults::SLACK_READ_TIMEOUT.to_f, budget].min
44
+ http.write_timeout = [Defaults::SLACK_WRITE_TIMEOUT.to_f, budget].min
45
+ transmitted = true
46
+ http.request(request) do |response|
47
+ bytes = 0
48
+ response.read_body do |chunk|
49
+ bytes += chunk.bytesize
50
+ raise ResponseTooLarge if bytes > MAX_RESPONSE_BYTES
51
+ raise DeadlineExceeded if self.class.monotonic >= deadline
52
+ end
53
+ raise DeadlineExceeded if self.class.monotonic >= deadline
54
+
55
+ code = response.code.to_i
56
+ retry_after = response["Retry-After"].to_s
57
+ seconds = retry_after.match?(/\A\d+\z/) ? retry_after.to_i : nil
58
+ result = classify(code, seconds)
59
+ end
60
+ end
61
+ end
62
+ result
63
+ rescue WebhookValidator::Invalid, JSON::GeneratorError
64
+ Result.new(status: :failed, error_code: "invalid_webhook", unknown: false)
65
+ rescue OpenSSL::SSL::SSLError
66
+ Result.new(status: :failed, error_code: transmitted ? "delivery_outcome_unknown" : "tls_failure",
67
+ unknown: transmitted)
68
+ rescue DeadlineExceeded, Timeout::Error
69
+ Result.new(status: :retry, error_code: transmitted ? "delivery_outcome_unknown" : "network_timeout",
70
+ unknown: transmitted)
71
+ rescue SocketError, SystemCallError, IOError, ResponseTooLarge, Net::HTTPBadResponse, Net::ProtocolError
72
+ Result.new(status: :retry, error_code: transmitted ? "delivery_outcome_unknown" : "network_error",
73
+ unknown: transmitted)
74
+ ensure
75
+ begin
76
+ http.finish if http&.started?
77
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
78
+ # Return fixed error codes, without Slack response bodies or exception messages.
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def classify(code, retry_after)
85
+ case code
86
+ when 200..299
87
+ Result.new(status: :delivered, http_status: code, unknown: false)
88
+ when 300..399
89
+ Result.new(status: :failed, http_status: code, error_code: "redirect_rejected", unknown: false)
90
+ when 429
91
+ if retry_after && retry_after > Defaults::NOTIFICATION_RETRY_AFTER_CAP
92
+ Result.new(status: :failed, http_status: code, error_code: "retry_after_out_of_bounds", unknown: false)
93
+ else
94
+ Result.new(status: :retry, http_status: code, error_code: "rate_limited",
95
+ retry_after: retry_after&.positive? ? retry_after : nil, unknown: false)
96
+ end
97
+ when 408, 500..599
98
+ Result.new(status: :retry, http_status: code, error_code: "upstream_unavailable", unknown: false)
99
+ else
100
+ Result.new(status: :failed, http_status: code, error_code: "slack_rejected", unknown: false)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Slack
5
+ class Renderer
6
+ LABELS = { "advance_reminder" => "Upcoming on-call shift", "shift_start" => "On-call shift started",
7
+ "assignment_change" => "On-call assignment changed", "next_assignment_change" => "Next assignment changed",
8
+ "test" => "Munawaba test message" }.freeze
9
+
10
+ def self.escape(value, limit: 255)
11
+ value.to_s.truncate(limit, omission: "…").gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
12
+ end
13
+
14
+ def self.person(person)
15
+ id = person.slack_member_id
16
+ id.present? && id.match?(/\A[A-Z][A-Z0-9]{1,31}\z/) ? "<@#{id}>" : escape(person.name)
17
+ end
18
+
19
+ def self.call(delivery:, schedule:, shift: nil)
20
+ context = Notifications::Context::V1.validate!(kind: delivery.kind, context: delivery.context)
21
+ lines = ["#{LABELS.fetch(delivery.kind)} · #{escape(schedule.name, limit: 120)}"]
22
+ if delivery.kind == "assignment_change"
23
+ previous, current = Person.find(context.fetch("previous_person_id")),
24
+ Person.find(context.fetch("new_person_id"))
25
+ lines << "#{person(previous)} → #{person(current)}"
26
+ override = ShiftOverride.find(context["override_id"] || context.fetch("ended_override_id"))
27
+ lines << "Reason: #{escape(override.reason, limit: 500)}" if override.reason.present?
28
+ elsif delivery.kind == "next_assignment_change"
29
+ lines << "#{person(Person.find(context.fetch("previous_next_person_id")))} → #{person(Person.find(context.fetch("new_next_person_id")))}"
30
+ shift ||= Shift.find(context.fetch("described_shift_id"))
31
+ elsif shift
32
+ lines << "On call: #{person(shift.effective_person)}"
33
+ end
34
+ if shift
35
+ zone = ActiveSupport::TimeZone[schedule.time_zone]
36
+ lines << "#{shift.starts_at.in_time_zone(zone).strftime("%b %-d, %Y %H:%M")} – #{shift.ends_at.in_time_zone(zone).strftime("%b %-d, %Y %H:%M")} (#{escape(schedule.time_zone)})"
37
+ else
38
+ lines << "Timezone: #{escape(schedule.time_zone)}"
39
+ end
40
+ if Munawaba.config.application_base_url
41
+ base = Munawaba.config.application_base_url.sub(%r{/+\z}, "")
42
+ lines << "<#{base}/schedules/#{schedule.id}|View schedule>"
43
+ end
44
+ { text: lines.join("\n"), unfurl_links: false, unfurl_media: false }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ module Munawaba
5
+ module Slack
6
+ class WebhookValidator
7
+ class Invalid < Munawaba::Error
8
+ def initialize(*) = super("Enter a valid allowed HTTPS Slack webhook")
9
+ end
10
+
11
+ def self.validate!(value)
12
+ raise Invalid unless value.is_a?(String) && !value.match?(/[\x00-\x20\x7f]/)
13
+
14
+ uri = URI.parse(value)
15
+ raise Invalid unless uri.is_a?(URI::HTTPS) && uri.port == 443 && uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil?
16
+ raise Invalid unless Munawaba.config.slack_allowed_hosts.include?(uri.host&.downcase)
17
+ raise Invalid unless uri.path.present? && uri.path != "/" && !uri.path.match?(/%(?:0[0-9a-f]|1[0-9a-f]|7f)/i)
18
+
19
+ if %w[hooks.slack.com hooks.slack-gov.com].include?(uri.host.downcase)
20
+ raise Invalid unless uri.path.match?(%r{\A/services/[A-Za-z0-9_-]+/[A-Za-z0-9_-]+/[A-Za-z0-9_-]+\z})
21
+ end
22
+ uri
23
+ rescue URI::InvalidURIError
24
+ raise Invalid
25
+ end
26
+
27
+ def self.valid?(value)
28
+ validate!(value)
29
+ true
30
+ rescue Invalid
31
+ false
32
+ end
33
+ end
34
+ end
35
+ end