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,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
|