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,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Conflicts
5
+ class Finder
6
+ # One bound statement provides one snapshot for both conflict branches.
7
+ SQL = <<~SQL.freeze
8
+ WITH proposed_slots AS (
9
+ SELECT * FROM jsonb_to_recordset($1::jsonb) AS proposed(
10
+ target_schedule_id bigint, target_shift_id bigint, coverage_revision bigint,
11
+ boundary_index bigint, effective_person_id bigint, starts_at timestamptz, ends_at timestamptz
12
+ )
13
+ ), pairs AS (
14
+ SELECT p.target_schedule_id AS a_schedule, p.target_shift_id AS a_shift,
15
+ p.coverage_revision AS a_revision, p.boundary_index AS a_boundary,
16
+ e.schedule_id AS b_schedule, e.id AS b_shift, e.coverage_revision AS b_revision,
17
+ e.boundary_index AS b_boundary, p.effective_person_id,
18
+ greatest(p.starts_at, e.starts_at) AS overlap_start, least(p.ends_at,e.ends_at) AS overlap_end
19
+ FROM proposed_slots p JOIN munawaba_shifts e
20
+ ON e.effective_person_id = p.effective_person_id AND e.schedule_id <> p.target_schedule_id
21
+ AND e.canceled_at IS NULL AND tstzrange(e.starts_at,e.ends_at,'[)') && tstzrange(p.starts_at,p.ends_at,'[)')
22
+ WHERE NOT EXISTS (SELECT 1 FROM proposed_slots r WHERE r.target_shift_id = e.id)
23
+ UNION ALL
24
+ SELECT l.target_schedule_id, l.target_shift_id, l.coverage_revision, l.boundary_index,
25
+ r.target_schedule_id, r.target_shift_id, r.coverage_revision, r.boundary_index, l.effective_person_id,
26
+ greatest(l.starts_at,r.starts_at), least(l.ends_at,r.ends_at)
27
+ FROM proposed_slots l JOIN proposed_slots r ON l.effective_person_id = r.effective_person_id
28
+ AND l.target_schedule_id <> r.target_schedule_id
29
+ AND (l.target_schedule_id,l.coverage_revision,l.boundary_index) < (r.target_schedule_id,r.coverage_revision,r.boundary_index)
30
+ AND tstzrange(l.starts_at,l.ends_at,'[)') && tstzrange(r.starts_at,r.ends_at,'[)')
31
+ ), oriented AS (
32
+ SELECT CASE WHEN (a_schedule,a_revision,a_boundary) < (b_schedule,b_revision,b_boundary)
33
+ THEN ARRAY[a_schedule,a_shift,a_revision,a_boundary,b_schedule,b_shift,b_revision,b_boundary,effective_person_id,
34
+ (extract(epoch FROM overlap_start)*1000000)::bigint,(extract(epoch FROM overlap_end)*1000000)::bigint]
35
+ ELSE ARRAY[b_schedule,b_shift,b_revision,b_boundary,a_schedule,a_shift,a_revision,a_boundary,effective_person_id,
36
+ (extract(epoch FROM overlap_start)*1000000)::bigint,(extract(epoch FROM overlap_end)*1000000)::bigint]
37
+ END AS fingerprint FROM pairs
38
+ ) SELECT array_to_json(fingerprint)::text AS fingerprint FROM oriented ORDER BY fingerprint;
39
+ SQL
40
+
41
+ def self.call(slots:)
42
+ return [] if slots.empty?
43
+
44
+ vector = slots.map do |slot|
45
+ { target_schedule_id: slot.fetch(:schedule_id), target_shift_id: slot[:id],
46
+ coverage_revision: slot.fetch(:coverage_revision), boundary_index: slot.fetch(:boundary_index),
47
+ effective_person_id: slot.fetch(:effective_person_id), starts_at: Canonical.instant(slot.fetch(:starts_at)), ends_at: Canonical.instant(slot.fetch(:ends_at)) }
48
+ end
49
+ bind = ActiveRecord::Relation::QueryAttribute.new("proposed_slots", JSON.generate(vector), ActiveRecord::Type::String.new)
50
+ rows = Shift.connection.exec_query(SQL, "Munawaba conflicts", [bind])
51
+ rows.map { |row| JSON.parse(row.fetch("fingerprint")) }.uniq.sort_by { |entry|
52
+ entry.map { |value|
53
+ value.nil? ? -1 : value
54
+ }
55
+ }.freeze
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Integrations
5
+ class PlannerEffects
6
+ def self.replace_epoch(schedule, old_intents, now)
7
+ planned_count = 0
8
+ old_intents.each do |row|
9
+ valid = Notifications::Validity.call(row, now: now, allow_old_notification_revision: true)
10
+ status = valid.status == :canceled ? :canceled : :stale
11
+ if row.kind != "test" && valid.valid? && %w[assignment_change next_assignment_change].include?(row.kind)
12
+ values = row.attributes.slice("shift_id", "kind", "coverage_revision", "assignment_version",
13
+ "timing_version", "rotation_revision", "expires_at")
14
+ context = Notifications::Context::V1.validate!(kind: row.kind, context: row.context)
15
+ NotificationDelivery.create!(**values, schedule_id: schedule.id, notification_revision: schedule.notification_revision,
16
+ event_key: row.event_key.sub(/notification:\d+/, "notification:#{schedule.notification_revision}"), context: context,
17
+ due_at: now, next_attempt_at: now, status: "pending")
18
+ planned_count += 1
19
+ end
20
+ row.finish!(status: status, code: valid.code || "notification_changed", now: now)
21
+ end
22
+ planned = Notifications::Planner.call(schedule: schedule, now: now, operation: "integration")
23
+ Notifications::WakeDispatcher.call if planned_count.positive? && planned.zero?
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Integrations
5
+ class Invalid < Munawaba::Error; end
6
+ SETTINGS = %w[slack_enabled notify_advance advance_notice_seconds notify_shift_start notify_assignment_change
7
+ notify_next_assignment_change].freeze
8
+
9
+ def self.call(operation:, schedule:, attributes: {}, actor: nil)
10
+ operation = operation.to_s
11
+ attributes = attributes.to_h.stringify_keys.slice(*SETTINGS, "slack_webhook_url", "lock_version")
12
+ Schedule.transaction(requires_new: true) do
13
+ current = Schedule.where(id: schedule.id).lock("FOR NO KEY UPDATE").first!
14
+ check_version = attributes.key?("lock_version") && (operation == "update" || attributes["lock_version"])
15
+ expected = attributes.delete("lock_version")
16
+ if check_version && Integer(expected, exception: false) != current.lock_version
17
+ message = operation == "update" ? "Review and save again." : "Review them again."
18
+ next Result.new(status: 409, record: current, errors: ["Integration settings changed. #{message}"])
19
+ end
20
+
21
+ now = Time.current
22
+ case operation
23
+ when "update" then update(current, attributes, actor, now)
24
+ when "remove" then remove(current, actor, now)
25
+ when "test" then test(current, actor, now)
26
+ else raise ArgumentError, "Unknown integration operation"
27
+ end
28
+ Result.new(status: 303, record: current, errors: [])
29
+ end
30
+ rescue ActiveRecord::RecordInvalid => error
31
+ Result.new(status: 422, record: operation == "update" ? error.record : schedule,
32
+ errors: error.record.errors.full_messages)
33
+ rescue Invalid => error
34
+ Result.new(status: 422, record: schedule, errors: [error.message])
35
+ end
36
+
37
+ def self.update(current, attributes, actor, now)
38
+ before = current.attributes.slice(*SETTINGS)
39
+ old_timestamp = current.slack_webhook_configured_at
40
+ webhook = attributes.delete("slack_webhook_url")
41
+ if webhook.present?
42
+ current.slack_webhook_url = webhook
43
+ current.slack_webhook_configured_at = now
44
+ end
45
+ current.assign_attributes(attributes)
46
+ return unless current.changed?
47
+
48
+ save(current, now)
49
+ changed = SETTINGS.select { |key| before[key] != current.public_send(key) }
50
+ event_type = if webhook.present?
51
+ old_timestamp ? "slack.replaced" : "slack.configured"
52
+ else
53
+ "slack.settings_changed"
54
+ end
55
+ Audit::Recorder.record!(event_type: event_type, schedule: current, actor: actor, occurred_at: now,
56
+ metadata: { changed_fields: changed, before: before.slice(*changed),
57
+ after: current.attributes.slice(*changed) })
58
+ end
59
+
60
+ def self.remove(current, actor, now)
61
+ raise Invalid, "No webhook is configured." unless current.slack_webhook_configured_at
62
+
63
+ enabled = current.slack_enabled
64
+ current.assign_attributes(slack_webhook_url: nil, slack_webhook_configured_at: nil, slack_enabled: false)
65
+ save(current, now)
66
+ Audit::Recorder.record!(event_type: "slack.removed", schedule: current, actor: actor, occurred_at: now,
67
+ metadata: { enabled_before: enabled })
68
+ end
69
+
70
+ def self.test(current, actor, now)
71
+ unless current.slack_enabled && current.slack_webhook_configured_at
72
+ raise Invalid, "Configure and enable Slack first."
73
+ end
74
+
75
+ row = NotificationDelivery.create!(schedule: current, kind: "test", status: "pending",
76
+ event_key: "schedule:#{current.id}:test:notification:#{current.notification_revision}:#{SecureRandom.uuid}",
77
+ notification_revision: current.notification_revision, context: Notifications::Context::V1.build(kind: "test"),
78
+ due_at: now, next_attempt_at: now, expires_at: now + 10.minutes)
79
+ Audit::Recorder.record!(event_type: "slack.test_requested", schedule: current, actor: actor, occurred_at: now,
80
+ metadata: { delivery_id: row.id })
81
+ Notifications::WakeDispatcher.call
82
+ end
83
+
84
+ def self.save(current, now)
85
+ old_intents = current.notification_deliveries.unsent.order(:id).lock("FOR NO KEY UPDATE").to_a
86
+ current.notification_revision += 1
87
+ current.save!
88
+ PlannerEffects.replace_epoch(current, old_intents, now)
89
+ end
90
+
91
+ private_class_method :update, :remove, :test, :save
92
+ end
93
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Notifications
5
+ module Context
6
+ class V1
7
+ class Invalid < Munawaba::Error
8
+ def initialize(*) = super("Invalid delivery context")
9
+ end
10
+
11
+ OVERRIDES = {
12
+ "override_created" => %w[override_id],
13
+ "override_superseded" => %w[override_id ended_override_id],
14
+ "override_revoked" => %w[ended_override_id],
15
+ "override_restored_to_base" => %w[ended_override_id]
16
+ }.freeze
17
+
18
+ def self.validate!(kind:, context:)
19
+ raise Invalid unless context.is_a?(Hash) && context.keys.all? { |key| key.is_a?(String) }
20
+ raise Invalid unless context["schema_version"].is_a?(Integer) && context["schema_version"] == 1
21
+
22
+ keys = ["schema_version"]
23
+ case kind.to_s
24
+ when "advance_reminder", "shift_start", "test"
25
+ nil
26
+ when "assignment_change"
27
+ transition = OVERRIDES[context["transition_type"]]
28
+ raise Invalid unless transition
29
+
30
+ keys += %w[transition_type previous_person_id new_person_id] + transition
31
+ raise Invalid if context["previous_person_id"] == context["new_person_id"]
32
+ when "next_assignment_change"
33
+ # Accept the old activity reference without requiring activity rows to exist.
34
+ context = context.except("operation_id")
35
+ keys += %w[previous_next_person_id new_next_person_id described_shift_id]
36
+ raise Invalid if context["previous_next_person_id"] == context["new_next_person_id"]
37
+ else
38
+ raise Invalid
39
+ end
40
+ raise Invalid unless keys.sort == context.keys.sort
41
+
42
+ keys.grep(/_id\z/).each do |key|
43
+ raise Invalid unless context[key].is_a?(Integer) && context[key] > 0
44
+ end
45
+ raise Invalid if JSON.generate(context).bytesize > 16_384
46
+
47
+ context.slice(*keys).freeze
48
+ rescue JSON::GeneratorError, EncodingError
49
+ raise Invalid
50
+ end
51
+
52
+ def self.valid?(kind:, context:)
53
+ validate!(kind: kind, context: context)
54
+ true
55
+ rescue Invalid
56
+ false
57
+ end
58
+
59
+ def self.build(kind:, **values)
60
+ validate!(kind: kind, context: { "schema_version" => 1 }.merge(values.stringify_keys))
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Notifications
5
+ class Deliver
6
+ def self.call(delivery_id, claim_token)
7
+ row = NotificationDelivery.find_by(id: delivery_id, claim_token: claim_token, status: "enqueued")
8
+ return unless row && row.lease_expires_at > Time.current
9
+ unless Munawaba.config.notifications_enabled?
10
+ return Dispatcher.reset(delivery_id, claim_token, Time.current, signal: false)
11
+ end
12
+
13
+ check = Validity.call(row)
14
+ unless check.valid?
15
+ reject_claim(delivery_id, claim_token, check)
16
+ return
17
+ end
18
+ payload = Slack::Renderer.call(delivery: row, schedule: check.schedule, shift: check.shift)
19
+ webhook = check.schedule.slack_webhook_url
20
+ deadline = Slack::Client.monotonic + Defaults::SLACK_TOTAL_TIMEOUT.to_f
21
+ authorized = NotificationDelivery.transaction do
22
+ current = NotificationDelivery.where(id: delivery_id, claim_token: claim_token,
23
+ status: "enqueued").lock("FOR NO KEY UPDATE").first
24
+ now = Time.current
25
+ next false unless current && current.lease_expires_at > now
26
+
27
+ unless Munawaba.config.notifications_enabled?
28
+ current.mark_pending!(at: now)
29
+ next false
30
+ end
31
+ final = Validity.call(current, now: now)
32
+ unless final.valid?
33
+ current.finish!(status: final.status, code: final.code, now: now)
34
+ request_timing_maintenance(current.schedule_id) if final.code == "timing_rules_changed"
35
+ next false
36
+ end
37
+ current.update!(status: "processing", processing_at: now, lease_expires_at: now + Defaults::NOTIFICATION_PROCESSING_LEASE,
38
+ attempt_count: current.attempt_count + 1, last_attempt_at: now, last_http_status: nil,
39
+ last_error_code: current.last_error_code == "delivery_outcome_unknown" ? current.last_error_code : nil)
40
+ true
41
+ end
42
+ return unless authorized
43
+
44
+ outcome = Slack::Client.call(webhook: webhook, payload: payload, deadline: deadline)
45
+ complete(delivery_id, claim_token, outcome)
46
+ rescue Context::V1::Invalid, ActiveRecord::Encryption::Errors::Base, Slack::WebhookValidator::Invalid
47
+ reject_claim(delivery_id, claim_token, Validity::Result.new(status: :stale, code: "invalid_delivery_context"))
48
+ end
49
+
50
+ def self.complete(id, token, outcome)
51
+ NotificationDelivery.transaction do
52
+ row = NotificationDelivery.where(id: id, claim_token: token,
53
+ status: "processing").lock("FOR NO KEY UPDATE").first
54
+ unless row
55
+ Munawaba.signal("notification_late_result")
56
+ next
57
+ end
58
+ now = Time.current
59
+ row.last_http_status = outcome.http_status
60
+ if outcome.status == :delivered
61
+ row.finish!(status: :delivered, now: now, http_status: outcome.http_status)
62
+ elsif outcome.status == :failed
63
+ row.last_error_code = "delivery_outcome_unknown" if outcome.unknown
64
+ row.finish!(status: :failed, code: outcome.error_code, now: now, http_status: outcome.http_status)
65
+ else
66
+ row.retry_later!(now: now, code: outcome.error_code, retry_after: outcome.retry_after,
67
+ unknown: outcome.unknown)
68
+ end
69
+ end
70
+ end
71
+
72
+ def self.reject_claim(id, token, result)
73
+ NotificationDelivery.transaction do
74
+ row = NotificationDelivery.where(id: id, claim_token: token,
75
+ status: "enqueued").lock("FOR NO KEY UPDATE").first
76
+ if row
77
+ row.finish!(status: result.status, code: result.code)
78
+ request_timing_maintenance(row.schedule_id) if result.code == "timing_rules_changed"
79
+ end
80
+ end
81
+ end
82
+
83
+ def self.request_timing_maintenance(schedule_id)
84
+ ActiveRecord.after_all_transactions_commit do
85
+ begin
86
+ MaintenanceJob.perform_later(schedule_id)
87
+ rescue StandardError
88
+ Munawaba.signal("timing_wake_failed")
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Notifications
5
+ class Dispatcher
6
+ def self.call(now: Time.current)
7
+ return 0 unless Munawaba.config.notifications_enabled?
8
+
9
+ limit = Defaults::NOTIFICATION_BATCH_SIZE
10
+ NotificationDelivery.transaction do
11
+ NotificationDelivery.where(status: "pending").where("expires_at <= ?", now)
12
+ .order(:expires_at, :id).limit(limit).lock("FOR UPDATE SKIP LOCKED").each do |delivery|
13
+ delivery.finish!(status: :stale, code: "expired", now: now)
14
+ end
15
+ end
16
+ claimed = NotificationDelivery.transaction do
17
+ next [] unless Munawaba.config.notifications_enabled?
18
+
19
+ NotificationDelivery.where(status: "pending").where("next_attempt_at <= ?", now)
20
+ .order(:next_attempt_at, :id).limit(limit).lock("FOR UPDATE SKIP LOCKED").map do |delivery|
21
+ token = SecureRandom.uuid
22
+ delivery.update!(status: "enqueued", next_attempt_at: nil, claim_token: token,
23
+ enqueued_at: now, processing_at: nil, lease_expires_at: now + Defaults::NOTIFICATION_ENQUEUE_LEASE)
24
+ [delivery.id, token]
25
+ end
26
+ end
27
+ claimed.each do |id, token|
28
+ begin
29
+ job = DeliverNotificationJob.perform_later(id, token)
30
+ reset(id, token, now) unless job && job.successfully_enqueued?
31
+ rescue StandardError
32
+ reset(id, token, now)
33
+ end
34
+ end
35
+ claimed.size
36
+ end
37
+
38
+ def self.reset(id, token, now, signal: true)
39
+ NotificationDelivery.transaction do
40
+ row = NotificationDelivery.where(id: id, claim_token: token,
41
+ status: "enqueued").lock("FOR NO KEY UPDATE").first
42
+ row.mark_pending!(at: now) if row
43
+ end
44
+ Munawaba.signal("notification_enqueue_failed") if signal
45
+ rescue ActiveRecord::ActiveRecordError
46
+ Munawaba.signal("notification_enqueue_reset_failed")
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Notifications
5
+ class Planner
6
+ def self.call(schedule:, now:, operation:, shifts: [], previous_person_id: nil, override: nil,
7
+ ended_override: nil, invalidation_shift_ids: nil)
8
+ new(schedule, now).call(operation: operation.to_s, shifts: shifts, previous_person_id: previous_person_id,
9
+ override: override, ended_override: ended_override, invalidation_shift_ids: invalidation_shift_ids)
10
+ end
11
+
12
+ def initialize(schedule, now)
13
+ @schedule, @now = schedule, now
14
+ @planned_count = 0
15
+ end
16
+
17
+ def call(operation:, shifts:, previous_person_id:, override:, ended_override:,
18
+ invalidation_shift_ids: nil)
19
+ invalidate(invalidation_shift_ids)
20
+ rows = shifts.present? ? shifts : @schedule.shifts.live.where(coverage_revision: @schedule.coverage_revision).where(
21
+ "ends_at > ?", @now
22
+ ).order(:id)
23
+ rows = rows.to_a
24
+ rows.each { |shift| plan_shift(shift) }
25
+ if %w[override revoke restore_to_base].include?(operation) && previous_person_id && rows.one?
26
+ plan_assignment(rows.first, previous_person_id, override, ended_override)
27
+ elsif %w[rotation deactivate].include?(operation) && previous_person_id
28
+ shift = @schedule.shifts.live.where(coverage_revision: @schedule.coverage_revision).where("starts_at > ?", @now).order(
29
+ :starts_at, :id
30
+ ).first
31
+ plan_next(shift, previous_person_id) if shift
32
+ end
33
+ WakeDispatcher.call if @planned_count.positive?
34
+ @planned_count
35
+ end
36
+
37
+ def enabled?(kind)
38
+ return false unless @schedule.slack_webhook_configured_at
39
+ return false unless @schedule.slack_enabled
40
+
41
+ setting = Validity::KIND_SETTINGS[kind]
42
+ return false if setting && !@schedule.public_send(setting)
43
+
44
+ true
45
+ end
46
+
47
+ def invalidate(shift_ids = nil)
48
+ scope = @schedule.notification_deliveries.unsent
49
+ if shift_ids
50
+ # Compare JSON IDs as text so malformed context cannot cause a SQL cast error.
51
+ scope = scope.where(
52
+ "shift_id IN (?) OR (kind = 'next_assignment_change' AND context->>'described_shift_id' IN (?))", shift_ids, shift_ids.map(&:to_s)
53
+ )
54
+ end
55
+ scope.order(:id).lock("FOR NO KEY UPDATE").each do |delivery|
56
+ validity = Validity.call(delivery, now: @now)
57
+ next if validity.valid?
58
+
59
+ replan_transition(delivery) if validity.code == "timing_changed"
60
+ delivery.finish!(status: validity.status, code: validity.code, now: @now)
61
+ end
62
+ end
63
+
64
+ def plan_shift(shift)
65
+ return if shift.canceled_at || shift.ends_at <= @now || shift.coverage_revision != @schedule.coverage_revision
66
+ return unless %w[scheduled active pausing].include?(@schedule.state)
67
+
68
+ create_shift_intent(shift, "advance_reminder", due_at: shift.starts_at - @schedule.advance_notice_seconds,
69
+ expires_at: shift.starts_at)
70
+ create_shift_intent(shift, "shift_start", due_at: shift.starts_at,
71
+ expires_at: shift.starts_at + Munawaba::Defaults::START_NOTIFICATION_GRACE_PERIOD)
72
+ end
73
+
74
+ def create_shift_intent(shift, kind, due_at:, expires_at:, context: Context::V1.build(kind: kind))
75
+ return unless enabled?(kind)
76
+ return if expires_at <= @now
77
+
78
+ key = "shift:#{shift.id}:#{kind}:assignment:#{shift.assignment_version}:timing:#{shift.timing_version}:notification:#{@schedule.notification_revision}"
79
+ create(kind: kind, event_key: key, shift_id: shift.id, coverage_revision: shift.coverage_revision,
80
+ assignment_version: shift.assignment_version, timing_version: shift.timing_version,
81
+ context: context, due_at: [due_at, expires_at - 0.000001].min, expires_at: expires_at)
82
+ end
83
+
84
+ def plan_assignment(shift, previous, override, ended)
85
+ return if previous.to_i == shift.effective_person_id
86
+
87
+ transition = ended ? "override_#{ended.end_reason}" : "override_created"
88
+ values = { transition_type: transition, previous_person_id: previous.to_i,
89
+ new_person_id: shift.effective_person_id }
90
+ values[:override_id] = override.id if override
91
+ values[:ended_override_id] = ended.id if ended
92
+ origin = override&.created_at || ended&.ended_at
93
+ return unless origin
94
+
95
+ context = Context::V1.build(kind: "assignment_change", **values)
96
+ create_shift_intent(shift, "assignment_change", due_at: origin,
97
+ expires_at: [shift.ends_at, origin + 24.hours].min, context: context)
98
+ end
99
+
100
+ def plan_next(shift, previous)
101
+ return if previous.to_i == shift.base_person_id
102
+ return unless enabled?("next_assignment_change")
103
+
104
+ expires_at = [shift.starts_at, @now + 24.hours].min
105
+ return if expires_at <= @now
106
+
107
+ context = Context::V1.build(kind: "next_assignment_change",
108
+ previous_next_person_id: previous.to_i, new_next_person_id: shift.base_person_id, described_shift_id: shift.id)
109
+ create(kind: "next_assignment_change", event_key: next_key(shift), context: context, coverage_revision: shift.coverage_revision,
110
+ rotation_revision: @schedule.rotation_revision, timing_version: shift.timing_version, due_at: @now, expires_at: expires_at)
111
+ end
112
+
113
+ def next_key(shift)
114
+ "schedule:#{@schedule.id}:next_assignment_change:coverage:#{@schedule.coverage_revision}:rotation:#{@schedule.rotation_revision}:timing:#{shift.timing_version}:notification:#{@schedule.notification_revision}"
115
+ end
116
+
117
+ def replan_transition(delivery)
118
+ return unless %w[assignment_change next_assignment_change].include?(delivery.kind)
119
+ return unless enabled?(delivery.kind)
120
+
121
+ context = Context::V1.validate!(kind: delivery.kind, context: delivery.context)
122
+ shift = Shift.find(delivery.shift_id || context.fetch("described_shift_id"))
123
+ candidate = delivery.dup
124
+ candidate.timing_version = shift.timing_version
125
+ candidate.notification_revision = @schedule.notification_revision
126
+ return unless Validity.call(candidate, now: @now).valid?
127
+
128
+ if delivery.kind == "assignment_change"
129
+ transition = ShiftOverride.find(context["override_id"] || context.fetch("ended_override_id"))
130
+ origin = context["override_id"] ? transition.created_at : transition.ended_at
131
+ create_shift_intent(shift, delivery.kind, due_at: origin, expires_at: [shift.ends_at, origin + 24.hours].min,
132
+ context: context)
133
+ else
134
+ expiry = [shift.starts_at, delivery.created_at + 24.hours].min
135
+ return if expiry <= @now
136
+
137
+ create(kind: delivery.kind, event_key: next_key(shift), context: context, coverage_revision: shift.coverage_revision,
138
+ rotation_revision: @schedule.rotation_revision, timing_version: shift.timing_version, due_at: delivery.due_at, expires_at: expiry)
139
+ end
140
+ rescue Context::V1::Invalid, ActiveRecord::RecordNotFound
141
+ nil
142
+ end
143
+
144
+ def create(**attributes)
145
+ return if NotificationDelivery.exists?(event_key: attributes.fetch(:event_key))
146
+
147
+ row = NotificationDelivery.create!(**attributes, schedule_id: @schedule.id, notification_revision: @schedule.notification_revision,
148
+ status: "pending", next_attempt_at: [attributes.fetch(:due_at), @now].max)
149
+ @planned_count += 1
150
+ row
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Notifications
5
+ class ReclaimExpiredLeases
6
+ def self.call(now: Time.current)
7
+ NotificationDelivery.transaction do
8
+ NotificationDelivery.where(status: %w[enqueued processing]).where("lease_expires_at <= ?", now)
9
+ .order(:lease_expires_at, :id).limit(Defaults::NOTIFICATION_BATCH_SIZE)
10
+ .lock("FOR UPDATE SKIP LOCKED").each do |row|
11
+ was_processing = row.status == "processing"
12
+ row.last_error_code = "delivery_outcome_unknown" if was_processing
13
+ validity = Validity.call(row, now: now)
14
+ if !validity.valid?
15
+ row.finish!(status: validity.status, code: validity.code, now: now)
16
+ Deliver.request_timing_maintenance(row.schedule_id) if validity.code == "timing_rules_changed"
17
+ elsif was_processing
18
+ row.retry_later!(now: now, code: "delivery_outcome_unknown", unknown: true, validity: validity)
19
+ else
20
+ row.mark_pending!(at: now)
21
+ end
22
+ end.size
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Notifications
5
+ class RetryFailed
6
+ def self.call(delivery:, actor: nil, acknowledge_duplicate: false)
7
+ reference = NotificationDelivery.uncached { NotificationDelivery.find(delivery.id) }
8
+ parsed = Context::V1.validate!(kind: reference.kind, context: reference.context)
9
+ described_shift_id = reference.shift_id || parsed["described_shift_id"]
10
+ result = nil
11
+ NotificationDelivery.transaction(requires_new: true) do
12
+ schedule = Schedule.where(id: reference.schedule_id).lock("FOR NO KEY UPDATE").first!
13
+ shift = Shift.where(id: described_shift_id).lock("FOR NO KEY UPDATE").first! if described_shift_id
14
+ original = NotificationDelivery.where(id: reference.id).lock("FOR NO KEY UPDATE").first!
15
+ authoritative_context = Context::V1.validate!(kind: original.kind, context: original.context)
16
+ unless original.schedule_id == schedule.id &&
17
+ (original.shift_id || authoritative_context["described_shift_id"]) == described_shift_id &&
18
+ (!shift || shift.schedule_id == schedule.id)
19
+ raise Context::V1::Invalid
20
+ end
21
+
22
+ now = Time.current
23
+ validity = Validity.call(original, now: now, allow_old_notification_revision: true)
24
+ if original.status != "failed" || !validity.valid? || NotificationDelivery.exists?(retry_of_delivery_id: original.id)
25
+ result = Result.new(status: 422, record: original,
26
+ errors: ["This delivery is no longer eligible for retry."])
27
+ next
28
+ end
29
+ if original.last_error_code == "delivery_outcome_unknown" && !ActiveModel::Type::Boolean.new.cast(acknowledge_duplicate)
30
+ result = Result.new(status: 422, record: original,
31
+ errors: ["Acknowledge that retrying may post a duplicate message."])
32
+ next
33
+ end
34
+ values = original.attributes.slice("shift_id", "kind", "coverage_revision", "assignment_version",
35
+ "timing_version", "rotation_revision", "expires_at")
36
+ successor = NotificationDelivery.create!(**values, schedule: schedule, retry_of_delivery_id: original.id,
37
+ notification_revision: schedule.notification_revision, context: authoritative_context, event_key: "retry:#{original.id}:#{SecureRandom.uuid}",
38
+ status: "pending", due_at: now, next_attempt_at: now)
39
+ Audit::Recorder.record!(event_type: "notification.manual_retry_requested", schedule: schedule, shift_id: original.shift_id,
40
+ actor: actor, occurred_at: now, metadata: { predecessor_delivery_id: original.id, successor_delivery_id: successor.id,
41
+ kind: original.kind })
42
+ WakeDispatcher.call
43
+ result = Result.new(status: 303, record: successor, errors: [])
44
+ end
45
+ result
46
+ rescue Context::V1::Invalid, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique, ActiveRecord::RecordNotFound
47
+ Result.new(status: 422, record: delivery, errors: ["This delivery cannot be retried."])
48
+ end
49
+ end
50
+ end
51
+ end