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
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ VERSION = "0.1.0"
5
+ 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"