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