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
data/config/routes.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Munawaba::Engine.routes.draw do
|
|
4
|
+
root "overviews#show"
|
|
5
|
+
resource :overview, only: :show
|
|
6
|
+
resource :calendar, only: :show
|
|
7
|
+
resources :people, except: :destroy do
|
|
8
|
+
member do
|
|
9
|
+
get :deactivation
|
|
10
|
+
patch :deactivate
|
|
11
|
+
patch :reactivate
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
resources :schedules, except: :destroy do
|
|
15
|
+
member do
|
|
16
|
+
post :preview_activation
|
|
17
|
+
post :activate
|
|
18
|
+
post :pause
|
|
19
|
+
post :preview_resume
|
|
20
|
+
post :resume
|
|
21
|
+
post :cancel_scheduled
|
|
22
|
+
end
|
|
23
|
+
resource :rotation, only: %i[edit update] do
|
|
24
|
+
post :preview
|
|
25
|
+
end
|
|
26
|
+
resource :slack_integration, only: %i[edit update] do
|
|
27
|
+
post :test
|
|
28
|
+
delete :remove
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
resources :shifts, only: :show do
|
|
32
|
+
resource :override, only: %i[new create edit update] do
|
|
33
|
+
post :preview
|
|
34
|
+
patch :revoke
|
|
35
|
+
patch :restore
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
get "activity", to: "audit_events#index", as: :activity
|
|
39
|
+
resources :notification_deliveries, only: :index do
|
|
40
|
+
member { post :retry }
|
|
41
|
+
end
|
|
42
|
+
patch "theme", to: "themes#update", as: :theme
|
|
43
|
+
get "assets/:version/:id", to: "frontends#show", as: :frontend_asset, format: false,
|
|
44
|
+
constraints: { version: /[A-Za-z0-9._-]+/, id: /[A-Za-z0-9._-]+/ }
|
|
45
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateMunawabaPeople < ActiveRecord::Migration[7.2]
|
|
4
|
+
def up
|
|
5
|
+
unless connection.adapter_name == "PostgreSQL" && connection.database_version >= 150000
|
|
6
|
+
raise ActiveRecord::MigrationError, "Munawaba requires PostgreSQL 15 or later"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
create_table :munawaba_people do |t|
|
|
10
|
+
t.string :name, limit: 120, null: false
|
|
11
|
+
t.string :email, limit: 320, null: false
|
|
12
|
+
t.string :slack_member_id, limit: 32
|
|
13
|
+
t.boolean :active, null: false, default: true
|
|
14
|
+
t.column :deactivated_at, :timestamptz, precision: 6
|
|
15
|
+
t.integer :lock_version, null: false, default: 0
|
|
16
|
+
t.column :created_at, :timestamptz, precision: 6, null: false
|
|
17
|
+
t.column :updated_at, :timestamptz, precision: 6, null: false
|
|
18
|
+
end
|
|
19
|
+
add_index :munawaba_people, "lower(email)", unique: true, name: "mn_people_email_unique"
|
|
20
|
+
add_index :munawaba_people, :slack_member_id, unique: true, where: "slack_member_id IS NOT NULL",
|
|
21
|
+
name: "mn_people_slack_unique"
|
|
22
|
+
add_check_constraint :munawaba_people,
|
|
23
|
+
"name = btrim(name) AND name <> '' AND email = lower(btrim(email)) AND email <> ''", name: "mn_people_identity"
|
|
24
|
+
add_check_constraint :munawaba_people, "slack_member_id IS NULL OR slack_member_id ~ '^[A-Z][A-Z0-9]{1,31}$'",
|
|
25
|
+
name: "mn_people_slack_format"
|
|
26
|
+
add_check_constraint :munawaba_people, "active = (deactivated_at IS NULL)", name: "mn_people_active"
|
|
27
|
+
add_check_constraint :munawaba_people, "lock_version >= 0", name: "mn_people_lock_version"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def down
|
|
31
|
+
drop_table :munawaba_people
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateMunawabaSchedules < ActiveRecord::Migration[7.2]
|
|
4
|
+
def up
|
|
5
|
+
create_table :munawaba_schedules do |t|
|
|
6
|
+
t.string :name, limit: 120, null: false
|
|
7
|
+
t.string :state, null: false, default: "draft"
|
|
8
|
+
t.string :cadence, null: false
|
|
9
|
+
t.string :time_zone, limit: 100, null: false
|
|
10
|
+
t.date :anchor_local_date, null: false
|
|
11
|
+
t.integer :anchor_local_seconds, null: false
|
|
12
|
+
t.column :first_activated_at, :timestamptz, precision: 6
|
|
13
|
+
t.bigint :coverage_revision, null: false, default: 0
|
|
14
|
+
t.bigint :coverage_start_boundary
|
|
15
|
+
t.column :coverage_starts_at, :timestamptz, precision: 6
|
|
16
|
+
t.bigint :generated_through_boundary
|
|
17
|
+
t.bigint :rotation_revision, null: false, default: 0
|
|
18
|
+
t.bigint :rotation_effective_boundary
|
|
19
|
+
t.column :pause_effective_at, :timestamptz, precision: 6
|
|
20
|
+
t.bigint :lifecycle_revision, null: false, default: 0
|
|
21
|
+
t.bigint :notification_revision, null: false, default: 0
|
|
22
|
+
t.text :slack_webhook_url
|
|
23
|
+
t.boolean :slack_enabled, null: false, default: false
|
|
24
|
+
t.boolean :notify_advance, null: false, default: true
|
|
25
|
+
t.integer :advance_notice_seconds, null: false, default: 86400
|
|
26
|
+
t.boolean :notify_shift_start, null: false, default: true
|
|
27
|
+
t.boolean :notify_assignment_change, null: false, default: true
|
|
28
|
+
t.boolean :notify_next_assignment_change, null: false, default: true
|
|
29
|
+
t.column :slack_webhook_configured_at, :timestamptz, precision: 6
|
|
30
|
+
t.integer :lock_version, null: false, default: 0
|
|
31
|
+
t.column :created_at, :timestamptz, precision: 6, null: false
|
|
32
|
+
t.column :updated_at, :timestamptz, precision: 6, null: false
|
|
33
|
+
end
|
|
34
|
+
add_index :munawaba_schedules, "lower(name)", unique: true, name: "mn_schedules_name_unique"
|
|
35
|
+
add_index :munawaba_schedules, [:coverage_starts_at, :id], where: "state = 'scheduled'",
|
|
36
|
+
name: "mn_schedules_scheduled_due"
|
|
37
|
+
add_index :munawaba_schedules, [:pause_effective_at, :id], where: "state = 'pausing'",
|
|
38
|
+
name: "mn_schedules_pausing_due"
|
|
39
|
+
add_check_constraint :munawaba_schedules,
|
|
40
|
+
"name = btrim(name) AND name <> '' AND time_zone = btrim(time_zone) AND time_zone <> ''", name: "mn_schedules_names"
|
|
41
|
+
add_check_constraint :munawaba_schedules,
|
|
42
|
+
"state IN ('draft','scheduled','active','pausing','paused') AND cadence IN ('one_week','two_weeks','calendar_month')", name: "mn_schedules_enums"
|
|
43
|
+
add_check_constraint :munawaba_schedules,
|
|
44
|
+
"anchor_local_seconds BETWEEN 0 AND 86340 AND anchor_local_seconds % 60 = 0", name: "mn_schedules_anchor_seconds"
|
|
45
|
+
add_check_constraint :munawaba_schedules,
|
|
46
|
+
"coverage_revision >= 0 AND rotation_revision >= 0 AND lifecycle_revision >= 0 AND notification_revision >= 0 AND lock_version >= 0", name: "mn_schedules_versions"
|
|
47
|
+
add_check_constraint :munawaba_schedules,
|
|
48
|
+
"(coverage_start_boundary IS NULL OR coverage_start_boundary >= 0) AND (generated_through_boundary IS NULL OR generated_through_boundary >= 0) AND (rotation_effective_boundary IS NULL OR rotation_effective_boundary >= 0)", name: "mn_schedules_boundary_indexes"
|
|
49
|
+
add_check_constraint :munawaba_schedules, "generated_through_boundary >= coverage_start_boundary",
|
|
50
|
+
name: "mn_schedules_cursor"
|
|
51
|
+
add_check_constraint :munawaba_schedules,
|
|
52
|
+
"state NOT IN ('active','scheduled') OR rotation_effective_boundary BETWEEN coverage_start_boundary AND generated_through_boundary", name: "mn_schedules_rotation_boundary"
|
|
53
|
+
add_check_constraint :munawaba_schedules, "first_activated_at <= coverage_starts_at",
|
|
54
|
+
name: "mn_schedules_first_start"
|
|
55
|
+
add_check_constraint :munawaba_schedules, "coverage_revision > 0 OR state = 'draft'",
|
|
56
|
+
name: "mn_schedules_revision_state"
|
|
57
|
+
add_check_constraint :munawaba_schedules, "advance_notice_seconds BETWEEN 60 AND 2592000",
|
|
58
|
+
name: "mn_schedules_notice"
|
|
59
|
+
add_check_constraint :munawaba_schedules,
|
|
60
|
+
"(slack_webhook_url IS NULL) = (slack_webhook_configured_at IS NULL) AND (NOT slack_enabled OR slack_webhook_url IS NOT NULL)", name: "mn_schedules_slack_presence"
|
|
61
|
+
add_check_constraint :munawaba_schedules, <<~SQL.squish, name: "mn_schedules_state_fields"
|
|
62
|
+
(state = 'draft' AND first_activated_at IS NULL AND coverage_start_boundary IS NULL AND coverage_starts_at IS NULL AND generated_through_boundary IS NULL AND rotation_effective_boundary IS NULL AND pause_effective_at IS NULL)
|
|
63
|
+
OR (state = 'scheduled' AND coverage_revision > 0 AND coverage_start_boundary IS NOT NULL AND coverage_starts_at IS NOT NULL AND generated_through_boundary IS NOT NULL AND rotation_effective_boundary IS NOT NULL AND pause_effective_at IS NULL)
|
|
64
|
+
OR (state = 'active' AND coverage_revision > 0 AND first_activated_at IS NOT NULL AND coverage_start_boundary IS NOT NULL AND coverage_starts_at IS NOT NULL AND generated_through_boundary IS NOT NULL AND rotation_effective_boundary IS NOT NULL AND pause_effective_at IS NULL)
|
|
65
|
+
OR (state = 'pausing' AND first_activated_at IS NOT NULL AND coverage_start_boundary IS NOT NULL AND coverage_starts_at IS NOT NULL AND generated_through_boundary IS NOT NULL AND rotation_effective_boundary IS NULL AND pause_effective_at IS NOT NULL AND pause_effective_at > coverage_starts_at)
|
|
66
|
+
OR (state = 'paused' AND first_activated_at IS NOT NULL AND coverage_start_boundary IS NULL AND coverage_starts_at IS NULL AND generated_through_boundary IS NULL AND rotation_effective_boundary IS NULL AND pause_effective_at IS NULL)
|
|
67
|
+
SQL
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def down
|
|
71
|
+
drop_table :munawaba_schedules
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateMunawabaScheduleMemberships < ActiveRecord::Migration[7.2]
|
|
4
|
+
def up
|
|
5
|
+
create_table :munawaba_schedule_memberships do |t|
|
|
6
|
+
t.bigint :schedule_id, null: false
|
|
7
|
+
t.bigint :person_id, null: false
|
|
8
|
+
t.integer :position, null: false
|
|
9
|
+
t.column :created_at, :timestamptz, precision: 6, null: false
|
|
10
|
+
t.column :updated_at, :timestamptz, precision: 6, null: false
|
|
11
|
+
end
|
|
12
|
+
add_foreign_key :munawaba_schedule_memberships, :munawaba_schedules, column: :schedule_id, on_delete: :restrict
|
|
13
|
+
add_foreign_key :munawaba_schedule_memberships, :munawaba_people, column: :person_id, on_delete: :restrict
|
|
14
|
+
add_index :munawaba_schedule_memberships, [:schedule_id, :person_id], unique: true,
|
|
15
|
+
name: "mn_memberships_person_unique"
|
|
16
|
+
add_index :munawaba_schedule_memberships, [:schedule_id, :position], unique: true,
|
|
17
|
+
name: "mn_memberships_position_unique"
|
|
18
|
+
add_index :munawaba_schedule_memberships, [:person_id, :schedule_id], name: "mn_memberships_person_schedule"
|
|
19
|
+
add_check_constraint :munawaba_schedule_memberships, "position >= 0", name: "mn_memberships_position"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def down
|
|
23
|
+
drop_table :munawaba_schedule_memberships
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateMunawabaShifts < ActiveRecord::Migration[7.2]
|
|
4
|
+
def up
|
|
5
|
+
begin
|
|
6
|
+
execute "CREATE EXTENSION IF NOT EXISTS btree_gist"
|
|
7
|
+
rescue ActiveRecord::StatementInvalid => error
|
|
8
|
+
raise ActiveRecord::MigrationError,
|
|
9
|
+
"Munawaba requires btree_gist. Ask the database owner to CREATE EXTENSION btree_gist before installing. (#{error.cause.class})"
|
|
10
|
+
end
|
|
11
|
+
create_table :munawaba_shifts do |t|
|
|
12
|
+
t.bigint :schedule_id, null: false
|
|
13
|
+
t.bigint :coverage_revision, null: false
|
|
14
|
+
t.bigint :boundary_index, null: false
|
|
15
|
+
t.column :starts_at, :timestamptz, precision: 6, null: false
|
|
16
|
+
t.column :ends_at, :timestamptz, precision: 6, null: false
|
|
17
|
+
t.bigint :base_person_id, null: false
|
|
18
|
+
t.bigint :effective_person_id, null: false
|
|
19
|
+
t.bigint :rotation_revision, null: false, default: 0
|
|
20
|
+
t.bigint :assignment_version, null: false, default: 1
|
|
21
|
+
t.bigint :timing_version, null: false, default: 1
|
|
22
|
+
t.column :canceled_at, :timestamptz, precision: 6
|
|
23
|
+
t.string :cancellation_reason
|
|
24
|
+
t.column :generated_at, :timestamptz, precision: 6, null: false
|
|
25
|
+
t.integer :lock_version, null: false, default: 0
|
|
26
|
+
t.column :created_at, :timestamptz, precision: 6, null: false
|
|
27
|
+
t.column :updated_at, :timestamptz, precision: 6, null: false
|
|
28
|
+
end
|
|
29
|
+
add_foreign_key :munawaba_shifts, :munawaba_schedules, column: :schedule_id, on_delete: :restrict
|
|
30
|
+
add_foreign_key :munawaba_shifts, :munawaba_people, column: :base_person_id, on_delete: :restrict
|
|
31
|
+
add_foreign_key :munawaba_shifts, :munawaba_people, column: :effective_person_id, on_delete: :restrict
|
|
32
|
+
add_index :munawaba_shifts, [:schedule_id, :coverage_revision, :boundary_index], unique: true,
|
|
33
|
+
name: "mn_shifts_slot_unique"
|
|
34
|
+
add_index :munawaba_shifts, [:schedule_id, :boundary_index], unique: true, where: "canceled_at IS NULL",
|
|
35
|
+
name: "mn_shifts_live_slot_unique"
|
|
36
|
+
add_index :munawaba_shifts, [:schedule_id, :canceled_at, :starts_at], name: "mn_shifts_schedule_starts"
|
|
37
|
+
add_index :munawaba_shifts, [:effective_person_id, :starts_at, :ends_at], name: "mn_shifts_person_starts_ends"
|
|
38
|
+
execute "CREATE INDEX mn_shifts_person_overlap ON munawaba_shifts USING gist (effective_person_id, tstzrange(starts_at, ends_at, '[)')) WHERE canceled_at IS NULL"
|
|
39
|
+
execute "CREATE INDEX mn_shifts_calendar_overlap ON munawaba_shifts USING gist (tstzrange(starts_at, ends_at, '[)')) WHERE canceled_at IS NULL"
|
|
40
|
+
execute "ALTER TABLE munawaba_shifts ADD CONSTRAINT mn_shifts_no_live_overlap EXCLUDE USING gist (schedule_id WITH =, tstzrange(starts_at, ends_at, '[)') WITH &&) WHERE (canceled_at IS NULL) DEFERRABLE INITIALLY IMMEDIATE"
|
|
41
|
+
add_check_constraint :munawaba_shifts, "ends_at > starts_at", name: "mn_shifts_interval"
|
|
42
|
+
add_check_constraint :munawaba_shifts,
|
|
43
|
+
"coverage_revision > 0 AND boundary_index >= 0 AND rotation_revision >= 0 AND assignment_version > 0 AND timing_version > 0 AND lock_version >= 0", name: "mn_shifts_versions"
|
|
44
|
+
add_check_constraint :munawaba_shifts,
|
|
45
|
+
"(canceled_at IS NULL AND cancellation_reason IS NULL) OR (canceled_at IS NOT NULL AND cancellation_reason IS NOT NULL AND cancellation_reason IN ('pause','scheduled_run_canceled'))", name: "mn_shifts_cancellation"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def down
|
|
49
|
+
drop_table :munawaba_shifts
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateMunawabaShiftOverrides < ActiveRecord::Migration[7.2]
|
|
4
|
+
def up
|
|
5
|
+
create_table :munawaba_shift_overrides do |t|
|
|
6
|
+
t.bigint :shift_id, null: false
|
|
7
|
+
t.bigint :previous_person_id, null: false
|
|
8
|
+
t.bigint :replacement_person_id, null: false
|
|
9
|
+
t.text :reason
|
|
10
|
+
t.column :ended_at, :timestamptz, precision: 6
|
|
11
|
+
t.string :end_reason
|
|
12
|
+
t.column :created_at, :timestamptz, precision: 6, null: false
|
|
13
|
+
t.column :updated_at, :timestamptz, precision: 6, null: false
|
|
14
|
+
end
|
|
15
|
+
add_foreign_key :munawaba_shift_overrides, :munawaba_shifts, column: :shift_id, on_delete: :restrict
|
|
16
|
+
add_foreign_key :munawaba_shift_overrides, :munawaba_people, column: :previous_person_id, on_delete: :restrict
|
|
17
|
+
add_foreign_key :munawaba_shift_overrides, :munawaba_people, column: :replacement_person_id, on_delete: :restrict
|
|
18
|
+
add_index :munawaba_shift_overrides, :shift_id, unique: true, where: "ended_at IS NULL",
|
|
19
|
+
name: "mn_overrides_live_unique"
|
|
20
|
+
add_index :munawaba_shift_overrides, [:replacement_person_id, :shift_id], where: "ended_at IS NULL",
|
|
21
|
+
name: "mn_overrides_replacement_live"
|
|
22
|
+
add_check_constraint :munawaba_shift_overrides, "previous_person_id <> replacement_person_id",
|
|
23
|
+
name: "mn_overrides_people"
|
|
24
|
+
add_check_constraint :munawaba_shift_overrides,
|
|
25
|
+
"reason IS NULL OR (reason = btrim(reason) AND reason <> '' AND char_length(reason) <= 1000)", name: "mn_overrides_reason"
|
|
26
|
+
add_check_constraint :munawaba_shift_overrides,
|
|
27
|
+
"(ended_at IS NULL AND end_reason IS NULL) OR (ended_at IS NOT NULL AND end_reason IS NOT NULL AND ended_at >= created_at AND end_reason IN ('revoked','superseded','restored_to_base','shift_canceled'))", name: "mn_overrides_end"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def down
|
|
31
|
+
drop_table :munawaba_shift_overrides
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateMunawabaNotificationDeliveries < ActiveRecord::Migration[7.2]
|
|
4
|
+
def up
|
|
5
|
+
create_table :munawaba_notification_deliveries do |t|
|
|
6
|
+
t.bigint :schedule_id, null: false
|
|
7
|
+
t.bigint :shift_id
|
|
8
|
+
t.bigint :retry_of_delivery_id
|
|
9
|
+
t.string :kind, null: false
|
|
10
|
+
t.string :status, null: false, default: "pending"
|
|
11
|
+
t.string :event_key, limit: 255, null: false
|
|
12
|
+
t.bigint :coverage_revision
|
|
13
|
+
t.bigint :assignment_version
|
|
14
|
+
t.bigint :timing_version
|
|
15
|
+
t.bigint :rotation_revision
|
|
16
|
+
t.bigint :notification_revision, null: false
|
|
17
|
+
t.jsonb :context, null: false, default: {}
|
|
18
|
+
t.column :due_at, :timestamptz, precision: 6, null: false
|
|
19
|
+
t.column :next_attempt_at, :timestamptz, precision: 6
|
|
20
|
+
t.column :expires_at, :timestamptz, precision: 6, null: false
|
|
21
|
+
t.uuid :claim_token
|
|
22
|
+
t.column :enqueued_at, :timestamptz, precision: 6
|
|
23
|
+
t.column :processing_at, :timestamptz, precision: 6
|
|
24
|
+
t.column :lease_expires_at, :timestamptz, precision: 6
|
|
25
|
+
t.column :last_attempt_at, :timestamptz, precision: 6
|
|
26
|
+
t.integer :attempt_count, null: false, default: 0
|
|
27
|
+
t.integer :last_http_status
|
|
28
|
+
t.string :last_error_code, limit: 64
|
|
29
|
+
t.column :delivered_at, :timestamptz, precision: 6
|
|
30
|
+
t.column :created_at, :timestamptz, precision: 6, null: false
|
|
31
|
+
t.column :updated_at, :timestamptz, precision: 6, null: false
|
|
32
|
+
end
|
|
33
|
+
add_foreign_key :munawaba_notification_deliveries, :munawaba_schedules, column: :schedule_id, on_delete: :restrict
|
|
34
|
+
add_foreign_key :munawaba_notification_deliveries, :munawaba_shifts, column: :shift_id, on_delete: :restrict
|
|
35
|
+
add_foreign_key :munawaba_notification_deliveries, :munawaba_notification_deliveries,
|
|
36
|
+
column: :retry_of_delivery_id, on_delete: :restrict
|
|
37
|
+
add_index :munawaba_notification_deliveries, :event_key, unique: true, name: "mn_deliveries_event_unique"
|
|
38
|
+
add_index :munawaba_notification_deliveries, :retry_of_delivery_id, unique: true,
|
|
39
|
+
where: "retry_of_delivery_id IS NOT NULL", name: "mn_deliveries_retry_unique"
|
|
40
|
+
add_index :munawaba_notification_deliveries, [:next_attempt_at, :id], where: "status = 'pending'",
|
|
41
|
+
name: "mn_deliveries_pending_due"
|
|
42
|
+
add_index :munawaba_notification_deliveries, [:expires_at, :id], where: "status = 'pending'",
|
|
43
|
+
name: "mn_deliveries_pending_expiry"
|
|
44
|
+
add_index :munawaba_notification_deliveries, [:lease_expires_at, :id],
|
|
45
|
+
where: "status IN ('enqueued','processing')", name: "mn_deliveries_lease_expiry"
|
|
46
|
+
add_index :munawaba_notification_deliveries, [:shift_id, :kind, :assignment_version, :timing_version],
|
|
47
|
+
name: "mn_deliveries_shift_versions"
|
|
48
|
+
add_index :munawaba_notification_deliveries,
|
|
49
|
+
[:schedule_id, :kind, :coverage_revision, :rotation_revision, :timing_version, :notification_revision], name: "mn_deliveries_schedule_versions"
|
|
50
|
+
add_index :munawaba_notification_deliveries, [:schedule_id, :created_at, :id],
|
|
51
|
+
order: { created_at: :desc, id: :desc }, name: "mn_deliveries_schedule_history"
|
|
52
|
+
add_index :munawaba_notification_deliveries, [:created_at, :id], order: { created_at: :desc, id: :desc },
|
|
53
|
+
name: "mn_deliveries_history"
|
|
54
|
+
add_index :munawaba_notification_deliveries, [:status, :created_at, :id], order: { created_at: :desc, id: :desc },
|
|
55
|
+
name: "mn_deliveries_status_history"
|
|
56
|
+
add_index :munawaba_notification_deliveries, [:schedule_id, :status, :created_at, :id],
|
|
57
|
+
order: { created_at: :desc, id: :desc }, name: "mn_deliveries_schedule_status_history"
|
|
58
|
+
add_check_constraint :munawaba_notification_deliveries,
|
|
59
|
+
"kind IN ('advance_reminder','shift_start','assignment_change','next_assignment_change','test') AND status IN ('pending','enqueued','processing','delivered','failed','canceled','stale')", name: "mn_deliveries_enums"
|
|
60
|
+
add_check_constraint :munawaba_notification_deliveries, "event_key = btrim(event_key) AND event_key <> ''",
|
|
61
|
+
name: "mn_deliveries_event_key"
|
|
62
|
+
add_check_constraint :munawaba_notification_deliveries,
|
|
63
|
+
"attempt_count >= 0 AND notification_revision >= 0 AND (coverage_revision IS NULL OR coverage_revision > 0) AND (assignment_version IS NULL OR assignment_version > 0) AND (timing_version IS NULL OR timing_version > 0) AND (rotation_revision IS NULL OR rotation_revision >= 0)", name: "mn_deliveries_versions"
|
|
64
|
+
add_check_constraint :munawaba_notification_deliveries, "expires_at > due_at", name: "mn_deliveries_expiration"
|
|
65
|
+
add_check_constraint :munawaba_notification_deliveries,
|
|
66
|
+
"(next_attempt_at IS NOT NULL) = (status = 'pending') AND (claim_token IS NOT NULL) = (status IN ('enqueued','processing')) AND (lease_expires_at IS NOT NULL) = (status IN ('enqueued','processing')) AND (delivered_at IS NOT NULL) = (status = 'delivered')", name: "mn_deliveries_status_fields"
|
|
67
|
+
add_check_constraint :munawaba_notification_deliveries,
|
|
68
|
+
"(status <> 'pending' OR (enqueued_at IS NULL AND processing_at IS NULL)) AND (status <> 'enqueued' OR (enqueued_at IS NOT NULL AND processing_at IS NULL)) AND (status <> 'processing' OR (enqueued_at IS NOT NULL AND processing_at IS NOT NULL))", name: "mn_deliveries_claim_times"
|
|
69
|
+
add_check_constraint :munawaba_notification_deliveries,
|
|
70
|
+
"(last_error_code IS NULL OR last_error_code ~ '^[a-z][a-z0-9_]{0,63}$') AND (status <> 'failed' OR (last_error_code IS NOT NULL AND last_error_code <> '')) AND (last_http_status IS NULL OR last_http_status BETWEEN 100 AND 599)", name: "mn_deliveries_safe_result"
|
|
71
|
+
add_check_constraint :munawaba_notification_deliveries, <<~SQL.squish, name: "mn_deliveries_kind_fields"
|
|
72
|
+
(kind IN ('advance_reminder','shift_start','assignment_change') AND shift_id IS NOT NULL AND coverage_revision IS NOT NULL AND assignment_version IS NOT NULL AND timing_version IS NOT NULL AND rotation_revision IS NULL)
|
|
73
|
+
OR (kind = 'next_assignment_change' AND shift_id IS NULL AND coverage_revision IS NOT NULL AND rotation_revision IS NOT NULL AND timing_version IS NOT NULL AND assignment_version IS NULL)
|
|
74
|
+
OR (kind = 'test' AND shift_id IS NULL AND coverage_revision IS NULL AND assignment_version IS NULL AND timing_version IS NULL AND rotation_revision IS NULL)
|
|
75
|
+
SQL
|
|
76
|
+
add_check_constraint :munawaba_notification_deliveries,
|
|
77
|
+
"jsonb_typeof(context) = 'object' AND octet_length(context::text) <= 16384", name: "mn_deliveries_context"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def down
|
|
81
|
+
drop_table :munawaba_notification_deliveries
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateMunawabaAuditEvents < ActiveRecord::Migration[7.2]
|
|
4
|
+
def up
|
|
5
|
+
create_table :munawaba_audit_events do |t|
|
|
6
|
+
t.uuid :operation_id, null: false
|
|
7
|
+
t.string :event_type, limit: 100, null: false
|
|
8
|
+
t.bigint :schedule_id
|
|
9
|
+
t.bigint :person_id
|
|
10
|
+
t.bigint :shift_id
|
|
11
|
+
t.string :actor_type, limit: 100
|
|
12
|
+
t.string :actor_id, limit: 255
|
|
13
|
+
t.string :actor_name, limit: 255
|
|
14
|
+
t.jsonb :metadata, null: false, default: {}
|
|
15
|
+
t.column :occurred_at, :timestamptz, precision: 6, null: false
|
|
16
|
+
t.column :created_at, :timestamptz, precision: 6, null: false
|
|
17
|
+
end
|
|
18
|
+
add_foreign_key :munawaba_audit_events, :munawaba_schedules, column: :schedule_id, on_delete: :restrict
|
|
19
|
+
add_foreign_key :munawaba_audit_events, :munawaba_people, column: :person_id, on_delete: :restrict
|
|
20
|
+
add_foreign_key :munawaba_audit_events, :munawaba_shifts, column: :shift_id, on_delete: :restrict
|
|
21
|
+
add_index :munawaba_audit_events, [:occurred_at, :id], name: "mn_audit_history"
|
|
22
|
+
[:schedule_id, :person_id, :shift_id, :event_type].each do |column|
|
|
23
|
+
add_index :munawaba_audit_events, [column, :occurred_at, :id], order: { occurred_at: :desc, id: :desc },
|
|
24
|
+
name: "mn_audit_#{column}_history"
|
|
25
|
+
end
|
|
26
|
+
add_index :munawaba_audit_events, [:actor_type, :actor_id, :occurred_at, :id],
|
|
27
|
+
order: { occurred_at: :desc, id: :desc }, name: "mn_audit_actor_history"
|
|
28
|
+
add_index :munawaba_audit_events, :operation_id, name: "mn_audit_operation"
|
|
29
|
+
add_check_constraint :munawaba_audit_events,
|
|
30
|
+
"jsonb_typeof(metadata) = 'object' AND octet_length(metadata::text) <= 131072", name: "mn_audit_metadata"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def down
|
|
34
|
+
drop_table :munawaba_audit_events
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class SimplifyMunawabaActivity < ActiveRecord::Migration[7.2]
|
|
4
|
+
def up
|
|
5
|
+
execute "DROP TRIGGER IF EXISTS munawaba_audit_append_only ON munawaba_audit_events"
|
|
6
|
+
execute "DROP FUNCTION IF EXISTS munawaba_reject_audit_mutation()"
|
|
7
|
+
%w[mn_audit_catalog mn_audit_actor mn_audit_envelope].each do |name|
|
|
8
|
+
remove_check_constraint :munawaba_audit_events, name: name, if_exists: true
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def down
|
|
13
|
+
# Existing activity may now contain custom events and optional actors. Do not
|
|
14
|
+
# reinstate legacy restrictions that could reject or lock those records.
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Munawaba
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
include Rails::Generators::Migration
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
class_option :migrations, type: :boolean, default: true, desc: "Copy Munawaba's migrations"
|
|
12
|
+
class_option :initializer, type: :boolean, default: true, desc: "Copy Munawaba's initializer"
|
|
13
|
+
|
|
14
|
+
def self.next_migration_number(dirname)
|
|
15
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def install
|
|
19
|
+
template "munawaba.rb", "config/initializers/munawaba.rb" if options[:initializer]
|
|
20
|
+
if options[:migrations]
|
|
21
|
+
Dir[Munawaba::Engine.root.join("db/migrate/*.rb")].each do |path|
|
|
22
|
+
basename = File.basename(path).sub(/\A\d+_/, "")
|
|
23
|
+
next if Dir[File.join(destination_root, "db/migrate/*_#{basename}")].any?
|
|
24
|
+
|
|
25
|
+
migration_template path, "db/migrate/#{basename}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
say "Configure access in config/initializers/munawaba.rb."
|
|
29
|
+
say 'Add to config/routes.rb: mount Munawaba::Engine => "/on-call"'
|
|
30
|
+
say "Run bin/rails db:migrate, then schedule the background jobs:"
|
|
31
|
+
say "https://github.com/milkstrawai/munawaba#background-jobs"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Munawaba.configure do |config|
|
|
2
|
+
config.parent_controller = "ApplicationController"
|
|
3
|
+
|
|
4
|
+
# A callback must return exactly true to allow the request.
|
|
5
|
+
config.authenticate = ->(_controller) { false }
|
|
6
|
+
config.authorize = ->(_controller, _action, _record) { false }
|
|
7
|
+
# Optional attribution for activity entries: {type: "User", id: user.id.to_s, name: user.name}
|
|
8
|
+
config.actor = ->(_controller) { nil }
|
|
9
|
+
|
|
10
|
+
# Full engine URL, including the mount path, for links in Slack messages.
|
|
11
|
+
config.application_base_url = nil # "https://example.com/on-call"
|
|
12
|
+
config.default_schedule_time_zone = "UTC"
|
|
13
|
+
config.organization_time_zone = "UTC"
|
|
14
|
+
config.week_starts_on = :monday
|
|
15
|
+
config.job_queue_name = :munawaba
|
|
16
|
+
|
|
17
|
+
# Slack delivery requires Active Record Encryption and a configured webhook.
|
|
18
|
+
config.notifications_enabled = -> { ENV.fetch("MUNAWABA_NOTIFICATIONS_ENABLED", "false") == "true" }
|
|
19
|
+
# Maintenance mode pauses scheduling changes and Slack delivery.
|
|
20
|
+
# Before deploying timezone-data updates, follow:
|
|
21
|
+
# https://github.com/milkstrawai/munawaba/blob/main/docs/usage.md#timezone-data-updates
|
|
22
|
+
config.maintenance_mode = -> { ENV.fetch("MUNAWABA_MAINTENANCE_MODE", "false") == "true" }
|
|
23
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Munawaba
|
|
6
|
+
class Configuration
|
|
7
|
+
ATTRIBUTES = %i[parent_controller authenticate authorize actor application_base_url
|
|
8
|
+
default_schedule_time_zone organization_time_zone week_starts_on job_queue_name
|
|
9
|
+
calendar_future_limit calendar_past_limit calendar_max_span notifications_enabled
|
|
10
|
+
slack_allowed_hosts maintenance_mode].freeze
|
|
11
|
+
attr_accessor(*ATTRIBUTES)
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@parent_controller = "ApplicationController"
|
|
15
|
+
@default_schedule_time_zone = @organization_time_zone = "UTC"
|
|
16
|
+
@week_starts_on = :monday
|
|
17
|
+
@job_queue_name = :munawaba
|
|
18
|
+
@calendar_future_limit = 12.months
|
|
19
|
+
@calendar_past_limit = 24.months
|
|
20
|
+
@calendar_max_span = 3.months
|
|
21
|
+
@notifications_enabled = false
|
|
22
|
+
@maintenance_mode = false
|
|
23
|
+
@slack_allowed_hosts = ["hooks.slack.com"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate!
|
|
27
|
+
raise ConfigurationError,
|
|
28
|
+
"ActiveRecord.default_timezone must be :utc" unless ActiveRecord.default_timezone == :utc
|
|
29
|
+
|
|
30
|
+
%i[default_schedule_time_zone organization_time_zone].each { |name| TZInfo::Timezone.get(public_send(name)) }
|
|
31
|
+
%i[calendar_future_limit calendar_past_limit calendar_max_span].each do |name|
|
|
32
|
+
value = public_send(name)
|
|
33
|
+
raise ConfigurationError,
|
|
34
|
+
"#{name} must be positive" unless value.respond_to?(:to_f) && value.to_f.finite? && value.to_f > 0
|
|
35
|
+
end
|
|
36
|
+
if calendar_future_limit > Defaults::SHIFT_GENERATION_HORIZON
|
|
37
|
+
raise ConfigurationError, "calendar_future_limit exceeds the 13-month projection horizon"
|
|
38
|
+
end
|
|
39
|
+
raise ConfigurationError, "week_starts_on must be a weekday" unless Date::DAYS_INTO_WEEK.key?(week_starts_on)
|
|
40
|
+
raise ConfigurationError, "job_queue_name is required" if job_queue_name.to_s.strip.empty?
|
|
41
|
+
|
|
42
|
+
unless slack_allowed_hosts.is_a?(Array) && slack_allowed_hosts.present? && slack_allowed_hosts.all? { |host|
|
|
43
|
+
host.is_a?(String) && host.match?(/\A[a-z0-9]+(?:[.-][a-z0-9]+)*\z/)
|
|
44
|
+
}
|
|
45
|
+
raise ConfigurationError, "Slack hosts must be normalized exact hostnames"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
%i[notifications_enabled maintenance_mode].each do |name|
|
|
49
|
+
value = public_send(name)
|
|
50
|
+
unless value == true || value == false || (value.respond_to?(:call) && value.respond_to?(:arity) && value.arity == 0)
|
|
51
|
+
raise ConfigurationError, "#{name} must be boolean or a zero-argument callable"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
validate_base_url!
|
|
55
|
+
self
|
|
56
|
+
rescue TZInfo::InvalidTimezoneIdentifier
|
|
57
|
+
raise ConfigurationError, "Timezones must be valid IANA identifiers"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def notifications_enabled?
|
|
61
|
+
result = notifications_enabled.respond_to?(:call) ? notifications_enabled.call : notifications_enabled
|
|
62
|
+
Munawaba.signal("notifications_disabled") unless result == true
|
|
63
|
+
result == true && !maintenance_mode?
|
|
64
|
+
rescue StandardError
|
|
65
|
+
Munawaba.signal("notification_switch_error")
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def maintenance_mode?
|
|
70
|
+
value = maintenance_mode.respond_to?(:call) ? maintenance_mode.call : maintenance_mode
|
|
71
|
+
value != false
|
|
72
|
+
rescue StandardError
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def validate_base_url!
|
|
79
|
+
return if application_base_url.nil?
|
|
80
|
+
|
|
81
|
+
uri = URI.parse(application_base_url)
|
|
82
|
+
local = %w[localhost 127.0.0.1 ::1 [::1]].include?(uri.host)
|
|
83
|
+
scheme_valid = uri.scheme == "https" || (uri.scheme == "http" && local && !Rails.env.production?)
|
|
84
|
+
unless uri.host.present? && scheme_valid && uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil? && !application_base_url.match?(/[\x00-\x20\x7f]/)
|
|
85
|
+
raise ConfigurationError, "application_base_url must be a trusted absolute HTTPS engine URL"
|
|
86
|
+
end
|
|
87
|
+
rescue URI::InvalidURIError
|
|
88
|
+
raise ConfigurationError, "application_base_url is invalid"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Munawaba
|
|
4
|
+
module Defaults
|
|
5
|
+
MAXIMUM_SCHEDULE_MEMBERS = 100
|
|
6
|
+
SHIFT_GENERATION_HORIZON = 13.months.freeze
|
|
7
|
+
START_NOTIFICATION_GRACE_PERIOD = 1.hour.freeze
|
|
8
|
+
NOTIFICATION_ENQUEUE_LEASE = 30.minutes.freeze
|
|
9
|
+
NOTIFICATION_PROCESSING_LEASE = 2.minutes.freeze
|
|
10
|
+
NOTIFICATION_BATCH_SIZE = 100
|
|
11
|
+
NOTIFICATION_MAX_ATTEMPTS = 8
|
|
12
|
+
NOTIFICATION_BACKOFF_BASE = 30.seconds.freeze
|
|
13
|
+
NOTIFICATION_BACKOFF_CAP = 30.minutes.freeze
|
|
14
|
+
NOTIFICATION_RETRY_AFTER_CAP = 1.hour.freeze
|
|
15
|
+
SLACK_OPEN_TIMEOUT = 3
|
|
16
|
+
SLACK_READ_TIMEOUT = 5
|
|
17
|
+
SLACK_WRITE_TIMEOUT = 5
|
|
18
|
+
SLACK_TOTAL_TIMEOUT = 20
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Munawaba
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Munawaba
|
|
6
|
+
engine_name "munawaba_engine"
|
|
7
|
+
|
|
8
|
+
initializer "munawaba.filter_secrets" do |app|
|
|
9
|
+
app.config.filter_parameters += [:slack_webhook_url]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
config.after_initialize do
|
|
13
|
+
Munawaba.configuration.validate!
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/munawaba.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/core_ext"
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "active_job"
|
|
7
|
+
require "action_controller/railtie"
|
|
8
|
+
require "tzinfo"
|
|
9
|
+
require_relative "munawaba/version"
|
|
10
|
+
|
|
11
|
+
module Munawaba
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
class ConfigurationError < Error; end
|
|
14
|
+
|
|
15
|
+
def self.configuration
|
|
16
|
+
@configuration ||= Configuration.new
|
|
17
|
+
end
|
|
18
|
+
class << self
|
|
19
|
+
alias config configuration
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.configure
|
|
23
|
+
yield configuration
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.signal(event, **values)
|
|
27
|
+
ActiveSupport::Notifications.instrument("#{event}.munawaba", values)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
require_relative "munawaba/defaults"
|
|
32
|
+
require_relative "munawaba/configuration"
|
|
33
|
+
require_relative "munawaba/engine"
|