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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class OverridesController < ApplicationController
5
+ def new
6
+ @people = Person.where(active: true).order(:name)
7
+ render :new
8
+ end
9
+ alias_method :edit, :new
10
+
11
+ def preview
12
+ proposal(override_operation, @shift, override_attributes)
13
+ end
14
+
15
+ def create
16
+ confirm_override(:override)
17
+ end
18
+ alias_method :update, :create
19
+
20
+ def revoke
21
+ confirm_override(:revoke)
22
+ end
23
+
24
+ def restore
25
+ confirm_override(:restore_to_base)
26
+ end
27
+
28
+ private
29
+
30
+ def authorization_target
31
+ @shift = Shift.includes(:schedule, :base_person, :effective_person).find(params[:shift_id])
32
+ [:override_shifts, @shift]
33
+ end
34
+
35
+ def override_operation
36
+ { "apply" => :override, "revoke" => :revoke, "restore_to_base" => :restore_to_base }.fetch(
37
+ params[:operation].to_s, :override
38
+ )
39
+ end
40
+
41
+ def override_attributes
42
+ params.permit(:person_id, :reason, :lock_version).to_h.symbolize_keys
43
+ end
44
+
45
+ def confirm_override(operation)
46
+ command(operation, @shift, override_attributes, success_path: shift_path(@shift),
47
+ template: "munawaba/shared/proposal") do
48
+ refresh_proposal(operation, @shift, override_attributes)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class OverviewsController < ApplicationController
5
+ def show
6
+ @schedules = Schedule.order(:name).to_a
7
+ @now = Time.current
8
+ candidates = Shift.where(canceled_at: nil).where("ends_at > ?", @now)
9
+ .select("id, row_number() OVER (PARTITION BY schedule_id ORDER BY starts_at, id) AS position")
10
+ nearest_ids = Shift.from("(#{candidates.to_sql}) upcoming").where("position <= 2").select("id")
11
+ @shifts = Shift.where(id: nearest_ids).order(:starts_at).includes(:effective_person).to_a.group_by(&:schedule_id)
12
+ @projected_through = Shift.where(canceled_at: nil).group(:schedule_id).maximum(:ends_at)
13
+ @roster_counts = ScheduleMembership.group(:schedule_id).count
14
+ end
15
+
16
+ private
17
+
18
+ def authorization_target
19
+ [:read, :overview]
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class PeopleController < ApplicationController
5
+ def index
6
+ @people = Person.order(:name, :id)
7
+ @people = @people.where(active: params[:state] == "active") if %w[active inactive].include?(params[:state])
8
+ @people = @people.where("name ILIKE :q OR email ILIKE :q",
9
+ q: "%#{Person.sanitize_sql_like(params[:q].to_s)}%") if params[:q].present?
10
+ @people = after_name_cursor(@people).limit(page_size + 1).to_a
11
+ @more = @people.size > page_size
12
+ @people = @people.first(page_size)
13
+ @membership_counts = ScheduleMembership.where(person_id: @people.map(&:id)).group(:person_id).count
14
+ end
15
+
16
+ def show
17
+ @memberships = ScheduleMembership.where(person_id: @person.id).includes(:schedule).order(:schedule_id)
18
+ @shifts = Shift.where(effective_person_id: @person.id, canceled_at: nil).where("ends_at > ?", Time.current)
19
+ .includes(:schedule, :effective_person).order(:starts_at).limit(100)
20
+ end
21
+
22
+ def new
23
+ @person = Person.new
24
+ end
25
+
26
+ def create
27
+ @person = Person.new(person_attributes)
28
+ command(:create_person, @person, person_attributes, success_path: ->(record) {
29
+ person_path(record)
30
+ }, template: "new") do |result|
31
+ @person = result.record if result.record.is_a?(Person)
32
+ end
33
+ end
34
+
35
+ def edit; end
36
+
37
+ def update
38
+ command(:update_person, @person, person_attributes, success_path: person_path(@person),
39
+ template: "edit") do |_result|
40
+ @person.assign_attributes(person_attributes.except(:lock_version))
41
+ end
42
+ end
43
+
44
+ def deactivation
45
+ proposal(:deactivate, @person)
46
+ end
47
+
48
+ def deactivate
49
+ command(:deactivate, @person, {}, success_path: person_path(@person),
50
+ template: "munawaba/shared/proposal") do
51
+ refresh_proposal(:deactivate, @person, {})
52
+ end
53
+ end
54
+
55
+ def reactivate
56
+ command(:reactivate, @person, { lock_version: params[:lock_version] }, success_path: person_path(@person),
57
+ template: "show") {
58
+ show
59
+ }
60
+ end
61
+
62
+ private
63
+
64
+ def authorization_target
65
+ @person = Person.find(params[:id]) if params[:id]
66
+ [(%w[index show].include?(action_name) ? :read : :manage_people), @person || Person]
67
+ end
68
+
69
+ def person_attributes
70
+ record_parameters(:person).permit(:name, :email, :slack_member_id, :lock_version).to_h.symbolize_keys
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class RotationsController < ApplicationController
5
+ def edit
6
+ @person_ids = computed_roster(@schedule,
7
+ ScheduleMembership.where(schedule_id: @schedule.id).order(:position)).map { |membership|
8
+ membership.person_id.to_s
9
+ }
10
+ prepare_people
11
+ end
12
+
13
+ def preview
14
+ @person_ids = array_parameter(:person_ids).map(&:to_s).reject(&:blank?)
15
+ move = params[:move].to_s.split(":", 2)
16
+ index = @person_ids.index(move[1])
17
+ if index
18
+ case move[0]
19
+ when "up" then @person_ids[index - 1], @person_ids[index] = @person_ids[index],
20
+ @person_ids[index - 1] if index.positive?
21
+ when "down" then @person_ids[index + 1], @person_ids[index] = @person_ids[index],
22
+ @person_ids[index + 1] if index < @person_ids.length - 1
23
+ when "next" then @person_ids.unshift(@person_ids.delete_at(index))
24
+ when "remove" then @person_ids.delete_at(index)
25
+ end
26
+ end
27
+ @person_ids << params[:add_person_id].to_s if params[:add_person_id].present? && !@person_ids.include?(params[:add_person_id].to_s)
28
+ prepare_people
29
+ result = Commands.preview(operation: :rotation, subject: @schedule, attributes: rotation_attributes, actor: actor)
30
+ @preview = result.preview
31
+ @errors = Array(result.errors)
32
+ @operation, @subject, @attributes = :rotation, @schedule, rotation_attributes
33
+ render :edit, status: @errors.present? ? :unprocessable_entity : :ok
34
+ end
35
+
36
+ def update
37
+ @person_ids = array_parameter(:person_ids).map(&:to_s).reject(&:blank?)
38
+ prepare_people
39
+ command(:rotation, @schedule, rotation_attributes, success_path: schedule_path(@schedule),
40
+ template: "edit") do
41
+ refresh_proposal(:rotation, @schedule, rotation_attributes)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def authorization_target
48
+ @schedule = Schedule.find(params[:schedule_id])
49
+ [:manage_rotations, @schedule]
50
+ end
51
+
52
+ def prepare_people
53
+ @people_by_id = Person.where(id: @person_ids).index_by { |person| person.id.to_s }
54
+ @available_people = Person.where(active: true).where.not(id: @person_ids).order(:name)
55
+ end
56
+
57
+ def rotation_attributes
58
+ { person_ids: @person_ids, lock_version: params[:lock_version] || @schedule.lock_version }
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class SchedulesController < ApplicationController
5
+ def index
6
+ @schedules = after_name_cursor(Schedule.order(:name, :id)).limit(page_size + 1).to_a
7
+ @more = @schedules.size > page_size
8
+ @schedules = @schedules.first(page_size)
9
+ @membership_counts = ScheduleMembership.where(schedule_id: @schedules.map(&:id)).group(:schedule_id).count
10
+ end
11
+
12
+ def show
13
+ @memberships = computed_roster(@schedule,
14
+ ScheduleMembership.where(schedule_id: @schedule.id).includes(:person).order(:position))
15
+ @shifts = Shift.where(schedule_id: @schedule.id, canceled_at: nil).where("ends_at > ?", Time.current)
16
+ .includes(:effective_person, :base_person, :schedule).order(:starts_at).limit(100)
17
+ @current_shift = @shifts.find { |shift| shift.starts_at <= Time.current && shift.ends_at > Time.current }
18
+ prepare_resume_form if @schedule.state == "paused"
19
+ end
20
+
21
+ def new
22
+ @schedule = Schedule.new(cadence: "one_week", time_zone: Munawaba.config.default_schedule_time_zone,
23
+ anchor_local_date: Date.current, anchor_local_seconds: 9 * 3600)
24
+ end
25
+
26
+ def create
27
+ @schedule = Schedule.new(schedule_attributes)
28
+ command(:create_schedule, @schedule, schedule_attributes, success_path: ->(record) {
29
+ schedule_path(record)
30
+ }, template: "new") do |result|
31
+ @schedule = result.record if result.record.is_a?(Schedule)
32
+ end
33
+ end
34
+
35
+ def edit; end
36
+
37
+ def update
38
+ command(:update_schedule, @schedule, schedule_attributes, success_path: schedule_path(@schedule),
39
+ template: "edit") do |_result|
40
+ @schedule.assign_attributes(schedule_attributes.except(:lock_version))
41
+ end
42
+ end
43
+
44
+ def preview_activation
45
+ proposal(:activate, @schedule, lifecycle_attributes)
46
+ end
47
+
48
+ def preview_resume
49
+ if params[:edit_order] == "add"
50
+ prepare_resume_form
51
+ render :resume
52
+ return
53
+ end
54
+ proposal(:resume, @schedule, lifecycle_attributes)
55
+ end
56
+
57
+ def activate
58
+ confirm_lifecycle(:activate)
59
+ end
60
+
61
+ def resume
62
+ confirm_lifecycle(:resume)
63
+ end
64
+
65
+ def pause
66
+ return proposal(:pause, @schedule, lifecycle_attributes) if params[:proposal_token].blank?
67
+
68
+ confirm_lifecycle(:pause)
69
+ end
70
+
71
+ def cancel_scheduled
72
+ return proposal(:cancel_scheduled, @schedule, lifecycle_attributes) if params[:proposal_token].blank?
73
+
74
+ confirm_lifecycle(:cancel_scheduled)
75
+ end
76
+
77
+ private
78
+
79
+ def authorization_target
80
+ @schedule = Schedule.find(params[:id]) if params[:id]
81
+ [(%w[index show].include?(action_name) ? :read : :manage_schedules), @schedule || Schedule]
82
+ end
83
+
84
+ def schedule_attributes
85
+ attributes = record_parameters(:schedule).permit(:name, :cadence, :time_zone, :anchor_local_date,
86
+ :anchor_local_time, :lock_version).to_h.symbolize_keys
87
+ if attributes.key?(:anchor_local_time)
88
+ value = attributes.delete(:anchor_local_time).to_s
89
+ attributes[:anchor_local_seconds] = value.match?(/\A(?:[01]\d|2[0-3]):[0-5]\d\z/) ? value.split(":").map(&:to_i).then { |h, m|
90
+ (h * 3600) + (m * 60)
91
+ } : -1
92
+ end
93
+ attributes
94
+ end
95
+
96
+ def lifecycle_attributes
97
+ array_parameter(:person_ids) if params.key?(:person_ids)
98
+ params.permit(:mode, :boundary_index, :lock_version, :lifecycle_revision, person_ids: []).to_h.symbolize_keys
99
+ end
100
+
101
+ def confirm_lifecycle(operation)
102
+ command(operation, @schedule, lifecycle_attributes, success_path: schedule_path(@schedule),
103
+ template: "munawaba/shared/proposal") do
104
+ refresh_proposal(operation, @schedule, lifecycle_attributes)
105
+ end
106
+ end
107
+
108
+ def prepare_resume_form
109
+ @resume_people = Person.where(active: true).order(:name).to_a
110
+ ids = params.key?(:person_ids) ? array_parameter(:person_ids).reject(&:blank?) : ScheduleMembership.where(schedule_id: @schedule.id).order(:position).pluck(:person_id)
111
+ maximum = [Defaults::MAXIMUM_SCHEDULE_MEMBERS, @resume_people.length].min
112
+ @resume_ids = (ids + [nil]).first(maximum)
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class ShiftsController < ApplicationController
5
+ def show
6
+ @overrides = ShiftOverride.where(shift_id: @shift.id).order(created_at: :desc)
7
+ end
8
+
9
+ private
10
+
11
+ def authorization_target
12
+ @shift = Shift.includes(:schedule, :base_person, :effective_person).find(params[:id])
13
+ [:read, @shift]
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class SlackIntegrationsController < ApplicationController
5
+ def edit; end
6
+
7
+ def update
8
+ finish Integrations.call(operation: :update, schedule: @schedule, attributes: integration_attributes, actor: actor)
9
+ end
10
+
11
+ def remove
12
+ finish Integrations.call(operation: :remove, schedule: @schedule, actor: actor, attributes: { lock_version: params[:lock_version] })
13
+ end
14
+
15
+ def test
16
+ finish Integrations.call(operation: :test, schedule: @schedule, actor: actor, attributes: { lock_version: params[:lock_version] })
17
+ end
18
+
19
+ private
20
+
21
+ def authorization_target
22
+ @schedule = Schedule.find(params[:schedule_id])
23
+ [:manage_integrations, @schedule]
24
+ end
25
+
26
+ def integration_attributes
27
+ record_parameters(:integration).permit(:slack_webhook_url, :slack_enabled, :notify_advance,
28
+ :advance_notice_seconds, :notify_shift_start, :notify_assignment_change,
29
+ :notify_next_assignment_change, :lock_version).to_h.symbolize_keys
30
+ end
31
+
32
+ def finish(result)
33
+ if result.success?
34
+ redirect_to edit_schedule_slack_integration_path(@schedule), status: :see_other, notice: "Slack settings saved."
35
+ else
36
+ @errors = Array(result.errors)
37
+ @stale = result.status.to_i == 409
38
+ @schedule.reload
39
+ if action_name == "update"
40
+ @schedule.assign_attributes(integration_attributes.except(:slack_webhook_url, :lock_version))
41
+ end
42
+ render :edit, status: result.status
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class ThemesController < ApplicationController
5
+ def update
6
+ return head :unprocessable_entity unless %w[dark light].include?(params[:theme])
7
+
8
+ cookies.permanent[:munawaba_theme] =
9
+ { value: params[:theme], same_site: :lax, httponly: true, secure: request.ssl? }
10
+ redirect_back fallback_location: root_path, allow_other_host: false, status: :see_other
11
+ end
12
+
13
+ private
14
+
15
+ def authorization_target
16
+ [:read, :theme]
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module ApplicationHelper
5
+ CADENCES = { "one_week" => "One week", "two_weeks" => "Two weeks", "calendar_month" => "One calendar month" }.freeze
6
+
7
+ def page_title(title, subtitle = nil)
8
+ content_for(:title, title)
9
+ content_tag(:div, class: "mn-heading") do
10
+ safe_join([content_tag(:h1, title), (content_tag(:p, subtitle, class: "mn-muted") if subtitle)].compact)
11
+ end
12
+ end
13
+
14
+ def state_badge(value)
15
+ content_tag(:span, value.to_s.humanize, class: "mn-badge mn-badge-#{value.to_s.parameterize}")
16
+ end
17
+
18
+ def effective_schedule_state(schedule)
19
+ @mn_display_now ||= Time.current
20
+ if schedule.state == "scheduled" && schedule.coverage_starts_at && schedule.coverage_starts_at <= @mn_display_now
21
+ "active"
22
+ elsif schedule.state == "pausing" && schedule.pause_effective_at && schedule.pause_effective_at <= @mn_display_now
23
+ "paused"
24
+ else
25
+ schedule.state
26
+ end
27
+ end
28
+
29
+ def lifecycle_maintenance_warning(schedule)
30
+ case [schedule.state, effective_schedule_state(schedule)]
31
+ when ["scheduled", "active"]
32
+ "Coverage has started. Waiting for the next maintenance run."
33
+ when ["pausing", "paused"]
34
+ "Coverage has paused. Waiting for the next maintenance run before you can resume."
35
+ end
36
+ end
37
+
38
+ def shift_dst_adjustments(shift)
39
+ @mn_boundary_calculators ||= {}
40
+ @mn_boundary_metadata ||= {}
41
+ calculator = (@mn_boundary_calculators[shift.schedule_id] ||= Timing::BoundaryCalculator.new(shift.schedule))
42
+ [["Start", shift.boundary_index, shift.starts_at],
43
+ ["End", shift.boundary_index + 1, shift.ends_at]].filter_map do |edge, index, persisted_at|
44
+ boundary = (@mn_boundary_metadata[[shift.schedule_id, index]] ||= calculator.boundary(index))
45
+ # Started shifts keep their saved times after timezone rules change.
46
+ # Describe a DST adjustment only when it matches the saved boundary.
47
+ next if boundary.resolution == "exact" || boundary.resolved_at != persisted_at
48
+
49
+ boundary_adjustment_text(boundary, edge: edge, zone: shift.schedule.time_zone)
50
+ end
51
+ end
52
+
53
+ def boundary_adjustment_text(boundary, edge:, zone:)
54
+ nominal_time = format("%02d:%02d", boundary.nominal_local_seconds / 3600,
55
+ boundary.nominal_local_seconds % 3600 / 60)
56
+ offset = boundary.utc_offset_seconds
57
+ offset_text = format("UTC%s%02d:%02d", offset.negative? ? "-" : "+", offset.abs / 3600, offset.abs % 3600 / 60)
58
+ offset_text += format(":%02d", offset.abs % 60) unless (offset % 60).zero?
59
+ adjustment = boundary.adjustment_seconds
60
+ duration = (adjustment % 60).zero? ? pluralize(adjustment / 60, "minute") : pluralize(adjustment, "second")
61
+ resolution = boundary.resolution == "ambiguous_earlier" ? "earlier occurrence" : "moved forward"
62
+ "DST adjustment · #{edge}: scheduled #{boundary.nominal_local_date} #{nominal_time} #{zone} → #{local_time(
63
+ boundary.resolved_at, zone
64
+ )}, #{offset_text}; #{resolution}, #{duration}."
65
+ end
66
+
67
+ def local_time(value, zone = nil, format: "%b %-d, %Y · %H:%M")
68
+ return "—" unless value
69
+
70
+ value = Time.iso8601(value) if value.is_a?(String)
71
+ value.in_time_zone(zone || Munawaba.config.organization_time_zone).strftime(format)
72
+ end
73
+
74
+ def handoff_time(schedule)
75
+ seconds = schedule.anchor_local_seconds.to_i
76
+ format("%02d:%02d", seconds / 3600, seconds % 3600 / 60)
77
+ end
78
+
79
+ def cadence_name(schedule)
80
+ CADENCES.fetch(schedule.cadence, schedule.cadence.to_s.humanize)
81
+ end
82
+
83
+ def nav_link(label, path, key)
84
+ active = controller_name == key || (key == "schedules" && %w[rotations slack_integrations shifts
85
+ overrides].include?(controller_name))
86
+ link_to label, path, class: "mn-nav-link#{" mn-selected" if active}", aria: { current: ("page" if active) }
87
+ end
88
+
89
+ def model_errors(record, field)
90
+ return unless record && record.errors[field].present?
91
+
92
+ content_tag(:p, record.errors[field].join(", "), class: "mn-field-error",
93
+ id: "#{record.model_name.param_key}_#{field}_error")
94
+ end
95
+
96
+ def preview_value(key, default = nil)
97
+ return default unless @preview
98
+
99
+ value = @preview.public_send(key)
100
+ value.nil? ? default : value
101
+ end
102
+
103
+ def detail_value(key, default = nil)
104
+ details = preview_value(:details, {})
105
+ details[key] || default
106
+ end
107
+
108
+ def projection_slots
109
+ Array(preview_value(:projection, []))
110
+ end
111
+
112
+ def person_label(id)
113
+ @mn_person_labels ||= Person.where(id: (projection_slots.map { |slot| slot[:effective_person_id] } +
114
+ Array(detail_value(:ordered_person_ids)) + Array(detail_value(:schedule_plans)).flat_map { |plan|
115
+ Array(plan[:before_person_ids]) + Array(plan[:person_ids])
116
+ }).compact.uniq).pluck(:id, :name).to_h
117
+ @mn_person_labels[id.to_i] || Person.where(id: id).pick(:name) || "Person ##{id}"
118
+ end
119
+
120
+ def confirmation_target
121
+ case @operation.to_sym
122
+ when :activate then [activate_schedule_path(@subject), :post]
123
+ when :resume then [resume_schedule_path(@subject), :post]
124
+ when :rotation then [schedule_rotation_path(@subject), :patch]
125
+ when :deactivate then [deactivate_person_path(@subject), :patch]
126
+ when :pause then [pause_schedule_path(@subject), :post]
127
+ when :cancel_scheduled then [cancel_scheduled_schedule_path(@subject), :post]
128
+ when :revoke then [revoke_shift_override_path(@subject), :patch]
129
+ when :restore_to_base then [restore_shift_override_path(@subject), :patch]
130
+ else [shift_override_path(@subject), :post]
131
+ end
132
+ end
133
+
134
+ def proposal_heading
135
+ { activate: "Review activation", resume: "Review resume", rotation: "Review rotation", deactivate: "Review deactivation",
136
+ override: "Review override", revoke: "Review override revocation", restore_to_base: "Review restore to base",
137
+ pause: "Review pause", cancel_scheduled: "Review scheduled cancellation" }.fetch(@operation.to_sym)
138
+ end
139
+
140
+ def proposal_back_path
141
+ @subject.is_a?(Person) ? person_path(@subject) : @subject.is_a?(Shift) ? shift_path(@subject) : schedule_path(@subject)
142
+ end
143
+
144
+ def submitted_hidden_fields(attributes)
145
+ safe_join(attributes.flat_map do |key, value|
146
+ value.is_a?(Array) ? value.map { |item|
147
+ hidden_field_tag("#{key}[]", item, id: nil)
148
+ } : hidden_field_tag(key, value, id: nil)
149
+ end)
150
+ end
151
+
152
+ def cursor_for(record, column)
153
+ "#{record.public_send(column).utc.iso8601(6)}|#{record.id}"
154
+ end
155
+
156
+ def name_cursor_for(record)
157
+ Base64.urlsafe_encode64(JSON.generate([record.name, record.id]), padding: false)
158
+ end
159
+
160
+ def calendar_link(date, view = @view)
161
+ calendar_path(request.query_parameters.except("date", "until", "view").merge(date: date.iso8601, view: view))
162
+ end
163
+
164
+ def calendar_days
165
+ first = @from.beginning_of_week(Munawaba.config.week_starts_on)
166
+ last = (@until - 1).end_of_week(Munawaba.config.week_starts_on)
167
+ (first..last).to_a
168
+ end
169
+
170
+ def future_boundaries(schedule)
171
+ Timing::BoundaryCalculator.new(schedule).selectable_boundaries(now: Time.current).map do |boundary|
172
+ [local_time(boundary.resolved_at, schedule.time_zone), boundary.index]
173
+ end
174
+ end
175
+
176
+ def readable_conflict(conflict, zone)
177
+ @mn_conflict_schedules ||= Schedule.where(id: Array(preview_value(:conflicts, [])).flat_map { |entry|
178
+ [entry[0], entry[4]]
179
+ }).pluck(:id, :name).to_h
180
+ from = Time.at(Rational(conflict[9], 1_000_000)).utc
181
+ until_at = Time.at(Rational(conflict[10], 1_000_000)).utc
182
+ "#{person_label(conflict[8])} · #{@mn_conflict_schedules[conflict[0]]} and #{@mn_conflict_schedules[conflict[4]]} · #{local_time(
183
+ from, zone
184
+ )} – #{local_time(until_at, zone)} · #{zone}"
185
+ end
186
+
187
+ def shifts_on(day)
188
+ zone = ActiveSupport::TimeZone[@time_zone]
189
+ start_at = zone.local(day.year, day.month, day.day)
190
+ next_day = day + 1
191
+ end_at = zone.local(next_day.year, next_day.month, next_day.day)
192
+ @shifts.select { |shift| shift.starts_at < end_at && shift.ends_at > start_at }
193
+ end
194
+
195
+ def conflicts_for(shifts)
196
+ ids = shifts.map(&:id)
197
+ return {} if ids.empty?
198
+
199
+ sql = Shift.sanitize_sql_array([<<~SQL, ids])
200
+ SELECT a.id, b.schedule_id, s.name FROM munawaba_shifts a
201
+ JOIN munawaba_shifts b ON a.effective_person_id = b.effective_person_id AND a.schedule_id <> b.schedule_id
202
+ AND b.canceled_at IS NULL AND tstzrange(a.starts_at, a.ends_at, '[)') && tstzrange(b.starts_at, b.ends_at, '[)')
203
+ JOIN munawaba_schedules s ON s.id = b.schedule_id WHERE a.id IN (?) AND a.canceled_at IS NULL
204
+ SQL
205
+ Shift.connection.select_all(sql).to_a.group_by { |row| row["id"].to_i }
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class ApplicationJob < ActiveJob::Base
5
+ queue_as { Munawaba.config.job_queue_name }
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class DeliverNotificationJob < ApplicationJob
5
+ def perform(delivery_id, claim_token)
6
+ if ActiveRecord::Base.connection.transaction_open?
7
+ raise Munawaba::Error, "Delivery jobs must run outside host database transactions"
8
+ end
9
+
10
+ Notifications::Deliver.call(delivery_id, claim_token)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class DispatchDueNotificationsJob < ApplicationJob
5
+ def perform = Notifications::Dispatcher.call
6
+ end
7
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ # Run daily, or enqueue a single schedule after a scheduling change.
5
+ class MaintainProjectionJob < ApplicationJob
6
+ def perform(schedule_id = nil, observed_revision = nil)
7
+ scope = Schedule.where(state: %w[scheduled active pausing])
8
+ scope = scope.where(id: schedule_id) if schedule_id
9
+ failure = nil
10
+ scope.find_each(batch_size: 100) do |schedule|
11
+ if schedule.state == "pausing"
12
+ next if observed_revision
13
+
14
+ Shifts::NormalizeFutureTiming.call(schedule: schedule)
15
+ else
16
+ Shifts::Project.call(schedule: schedule, observed_revision: observed_revision)
17
+ end
18
+ rescue StandardError => error
19
+ failure ||= error
20
+ Munawaba.signal("maintenance_failed", task: "projection", schedule_id: schedule.id)
21
+ end
22
+ raise failure if failure
23
+ end
24
+ end
25
+ end