munawaba 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +160 -0
  5. data/SECURITY.md +15 -0
  6. data/app/assets/munawaba/INTER-LICENSE.txt +92 -0
  7. data/app/assets/munawaba/JETBRAINS-MONO-LICENSE.txt +93 -0
  8. data/app/assets/munawaba/dashboard.css +272 -0
  9. data/app/assets/munawaba/dashboard.js +60 -0
  10. data/app/assets/munawaba/inter.woff2 +0 -0
  11. data/app/assets/munawaba/jetbrains-mono.woff2 +0 -0
  12. data/app/assets/munawaba/logo.png +0 -0
  13. data/app/controllers/munawaba/application_controller.rb +149 -0
  14. data/app/controllers/munawaba/audit_events_controller.rb +31 -0
  15. data/app/controllers/munawaba/calendars_controller.rb +47 -0
  16. data/app/controllers/munawaba/frontends_controller.rb +28 -0
  17. data/app/controllers/munawaba/notification_deliveries_controller.rb +41 -0
  18. data/app/controllers/munawaba/overrides_controller.rb +52 -0
  19. data/app/controllers/munawaba/overviews_controller.rb +22 -0
  20. data/app/controllers/munawaba/people_controller.rb +73 -0
  21. data/app/controllers/munawaba/rotations_controller.rb +61 -0
  22. data/app/controllers/munawaba/schedules_controller.rb +115 -0
  23. data/app/controllers/munawaba/shifts_controller.rb +16 -0
  24. data/app/controllers/munawaba/slack_integrations_controller.rb +46 -0
  25. data/app/controllers/munawaba/themes_controller.rb +19 -0
  26. data/app/helpers/munawaba/application_helper.rb +208 -0
  27. data/app/jobs/munawaba/application_job.rb +7 -0
  28. data/app/jobs/munawaba/deliver_notification_job.rb +13 -0
  29. data/app/jobs/munawaba/dispatch_due_notifications_job.rb +7 -0
  30. data/app/jobs/munawaba/maintain_projection_job.rb +25 -0
  31. data/app/jobs/munawaba/maintenance_job.rb +28 -0
  32. data/app/models/munawaba/application_record.rb +7 -0
  33. data/app/models/munawaba/audit_event.rb +16 -0
  34. data/app/models/munawaba/notification_delivery.rb +75 -0
  35. data/app/models/munawaba/person.rb +23 -0
  36. data/app/models/munawaba/schedule.rb +85 -0
  37. data/app/models/munawaba/schedule_membership.rb +13 -0
  38. data/app/models/munawaba/shift.rb +51 -0
  39. data/app/models/munawaba/shift_override.rb +31 -0
  40. data/app/services/munawaba/audit/recorder.rb +17 -0
  41. data/app/services/munawaba/canonical.rb +43 -0
  42. data/app/services/munawaba/commands.rb +741 -0
  43. data/app/services/munawaba/conflicts/finder.rb +59 -0
  44. data/app/services/munawaba/integrations/planner_effects.rb +27 -0
  45. data/app/services/munawaba/integrations.rb +93 -0
  46. data/app/services/munawaba/notifications/context/v1.rb +65 -0
  47. data/app/services/munawaba/notifications/deliver.rb +94 -0
  48. data/app/services/munawaba/notifications/dispatcher.rb +50 -0
  49. data/app/services/munawaba/notifications/planner.rb +154 -0
  50. data/app/services/munawaba/notifications/reclaim_expired_leases.rb +27 -0
  51. data/app/services/munawaba/notifications/retry_failed.rb +51 -0
  52. data/app/services/munawaba/notifications/validity.rb +90 -0
  53. data/app/services/munawaba/notifications/wake_dispatcher.rb +17 -0
  54. data/app/services/munawaba/preview.rb +5 -0
  55. data/app/services/munawaba/result.rb +7 -0
  56. data/app/services/munawaba/shifts/normalize_future_timing.rb +172 -0
  57. data/app/services/munawaba/shifts/project.rb +50 -0
  58. data/app/services/munawaba/shifts/projection_plan.rb +89 -0
  59. data/app/services/munawaba/slack/client.rb +105 -0
  60. data/app/services/munawaba/slack/renderer.rb +48 -0
  61. data/app/services/munawaba/slack/webhook_validator.rb +35 -0
  62. data/app/services/munawaba/timing/boundary_calculator.rb +92 -0
  63. data/app/services/munawaba/timing/persisted_interval_verifier.rb +26 -0
  64. data/app/views/layouts/munawaba/application.html.erb +61 -0
  65. data/app/views/munawaba/audit_events/index.html.erb +85 -0
  66. data/app/views/munawaba/calendars/_shift.html.erb +17 -0
  67. data/app/views/munawaba/calendars/show.html.erb +81 -0
  68. data/app/views/munawaba/notification_deliveries/index.html.erb +65 -0
  69. data/app/views/munawaba/overrides/new.html.erb +19 -0
  70. data/app/views/munawaba/overviews/show.html.erb +76 -0
  71. data/app/views/munawaba/people/_form.html.erb +25 -0
  72. data/app/views/munawaba/people/edit.html.erb +2 -0
  73. data/app/views/munawaba/people/index.html.erb +49 -0
  74. data/app/views/munawaba/people/new.html.erb +2 -0
  75. data/app/views/munawaba/people/show.html.erb +34 -0
  76. data/app/views/munawaba/rotations/edit.html.erb +39 -0
  77. data/app/views/munawaba/schedules/_form.html.erb +49 -0
  78. data/app/views/munawaba/schedules/_resume_form.html.erb +24 -0
  79. data/app/views/munawaba/schedules/edit.html.erb +2 -0
  80. data/app/views/munawaba/schedules/index.html.erb +46 -0
  81. data/app/views/munawaba/schedules/new.html.erb +2 -0
  82. data/app/views/munawaba/schedules/resume.html.erb +4 -0
  83. data/app/views/munawaba/schedules/show.html.erb +86 -0
  84. data/app/views/munawaba/shared/_empty.html.erb +6 -0
  85. data/app/views/munawaba/shared/_errors.html.erb +23 -0
  86. data/app/views/munawaba/shared/_navigation.html.erb +8 -0
  87. data/app/views/munawaba/shared/_projection.html.erb +20 -0
  88. data/app/views/munawaba/shared/_proposal_details.html.erb +108 -0
  89. data/app/views/munawaba/shared/_shifts.html.erb +50 -0
  90. data/app/views/munawaba/shared/proposal.html.erb +5 -0
  91. data/app/views/munawaba/shifts/show.html.erb +65 -0
  92. data/app/views/munawaba/slack_integrations/edit.html.erb +48 -0
  93. data/config/routes.rb +45 -0
  94. data/db/migrate/20260905000001_create_munawaba_people.rb +33 -0
  95. data/db/migrate/20260905000002_create_munawaba_schedules.rb +73 -0
  96. data/db/migrate/20260905000003_create_munawaba_schedule_memberships.rb +25 -0
  97. data/db/migrate/20260905000004_create_munawaba_shifts.rb +51 -0
  98. data/db/migrate/20260905000005_create_munawaba_shift_overrides.rb +33 -0
  99. data/db/migrate/20260905000006_create_munawaba_notification_deliveries.rb +83 -0
  100. data/db/migrate/20260905000007_create_munawaba_audit_events.rb +36 -0
  101. data/db/migrate/20260905000008_simplify_munawaba_activity.rb +16 -0
  102. data/lib/generators/munawaba/install_generator.rb +34 -0
  103. data/lib/generators/munawaba/templates/munawaba.rb +23 -0
  104. data/lib/munawaba/configuration.rb +91 -0
  105. data/lib/munawaba/defaults.rb +20 -0
  106. data/lib/munawaba/engine.rb +16 -0
  107. data/lib/munawaba/version.rb +5 -0
  108. data/lib/munawaba.rb +33 -0
  109. metadata +287 -0
@@ -0,0 +1,741 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ # Ruby entry point for trusted host code. HTTP requests pass through the
5
+ # engine's authentication and authorization callbacks before reaching here.
6
+ class Commands
7
+ class Stale < StandardError; end
8
+ class Invalid < StandardError; end
9
+ class Rediscover < StandardError; end
10
+ SIMPLE = %w[create_person update_person reactivate create_schedule update_schedule].freeze
11
+ attr_reader :operation, :subject, :attributes, :actor
12
+
13
+ def self.preview(operation:, subject:, attributes: {}, actor: nil)
14
+ ActiveRecord::Base.uncached do
15
+ new(operation: operation, subject: subject, attributes: attributes, actor: actor).preview
16
+ end
17
+ end
18
+
19
+ def self.call(operation:, subject:, attributes: {}, actor: nil, token: nil, acknowledge_conflicts: false)
20
+ ActiveRecord::Base.uncached do
21
+ new(operation: operation, subject: subject, attributes: attributes, actor: actor).call(token: token,
22
+ acknowledge_conflicts: acknowledge_conflicts)
23
+ end
24
+ end
25
+
26
+ def initialize(operation:, subject:, attributes: {}, actor: nil)
27
+ @operation, @subject, @attributes, @actor = operation.to_s, subject, attributes.to_h.symbolize_keys, actor
28
+ @operation_id = SecureRandom.uuid
29
+ end
30
+
31
+ def preview
32
+ return failure(422, "Administrative changes are temporarily unavailable.") if maintenance?
33
+
34
+ subject.reload if subject.persisted?
35
+ plan = build(now: Canonical.time(Time.current))
36
+ render_plan(plan)
37
+ rescue Invalid, ArgumentError, TypeError, ActiveRecord::RecordInvalid, Stale => error
38
+ failure(422,
39
+ error.is_a?(ActiveRecord::RecordInvalid) ? error.record.errors.full_messages : error.message.presence || "The requested action is no longer available.")
40
+ end
41
+
42
+ def call(token:, acknowledge_conflicts:)
43
+ return failure(422, "Administrative changes are temporarily unavailable.") if maintenance?
44
+ return simple_change if SIMPLE.include?(operation)
45
+
46
+ envelope = verifier.verified(token.to_s, purpose: "munawaba.#{operation}")
47
+ raise Stale unless envelope.is_a?(Hash) && envelope["schema_version"] == 1 && envelope["operation"] == operation && envelope["target_id"] == subject.id
48
+
49
+ attempts = 0
50
+ begin
51
+ ids = discover
52
+ ActiveRecord::Base.transaction(requires_new: true) do
53
+ now, locked_shifts = lock_rows(ids)
54
+ subject.reload
55
+ raise Rediscover unless complete_discovery?(ids, discover)
56
+
57
+ begin
58
+ plan = build(now: now, envelope: envelope, locked_shifts: locked_shifts)
59
+ rescue Invalid, ArgumentError, TypeError
60
+ raise Stale
61
+ end
62
+ raise Stale unless Canonical.equal?(Canonical.digest(plan[:evidence]), Canonical.digest(envelope["evidence"]))
63
+ raise Invalid, "Resolve the listed blockers before confirming." if plan[:blockers].present?
64
+ raise Invalid, "Acknowledge the assignment conflicts to continue." if plan[:conflicts].any? && !ActiveModel::Type::Boolean.new.cast(acknowledge_conflicts)
65
+
66
+ apply_plan(plan, now: now)
67
+ Result.new(status: 303, record: subject, errors: [])
68
+ end
69
+ rescue Rediscover
70
+ attempts += 1
71
+ retry if attempts < 4
72
+ raise Stale
73
+ end
74
+ rescue Stale, ActiveRecord::StaleObjectError
75
+ refreshed = stale_preview
76
+ Result.new(status: 409, record: subject,
77
+ errors: ["This confirmation is out of date. Review the refreshed preview and confirm again."], preview: refreshed.preview)
78
+ rescue Invalid, ArgumentError, TypeError, ActiveRecord::RecordInvalid => error
79
+ failure(422, error.is_a?(ActiveRecord::RecordInvalid) ? error.record.errors.full_messages : error.message,
80
+ refresh: true)
81
+ rescue ActiveRecord::RecordNotUnique
82
+ failure(422, "That name, email, or Slack member ID is already in use.")
83
+ end
84
+
85
+ private
86
+
87
+ def stale_preview
88
+ refreshed = preview
89
+ return refreshed if refreshed.preview
90
+
91
+ fallback = case operation
92
+ when "cancel_scheduled" then "pause"
93
+ when "pause" then "cancel_scheduled"
94
+ when "revoke" then "restore_to_base"
95
+ when "restore_to_base" then "revoke"
96
+ when "resume" then "resume"
97
+ end
98
+ return refreshed unless fallback
99
+
100
+ next_attributes = fallback == "resume" ? attributes.except(:boundary_index) : attributes
101
+ self.class.preview(operation: fallback, subject: subject, attributes: next_attributes, actor: actor)
102
+ end
103
+
104
+ def maintenance? = Munawaba.config.maintenance_mode?
105
+
106
+ def verifier
107
+ @verifier ||= ActiveSupport::MessageVerifier.new(
108
+ Rails.application.key_generator.generate_key("munawaba.proposals.v1", 32), digest: "SHA256", serializer: JSON
109
+ )
110
+ end
111
+
112
+ def failure(status, errors, refresh: false)
113
+ refreshed = refresh ? preview : nil
114
+ Result.new(status: status, record: subject, errors: Array(errors), preview: refreshed&.preview)
115
+ end
116
+
117
+ def render_plan(plan)
118
+ envelope = { schema_version: 1, operation: operation, target_id: subject.id, evidence: plan[:evidence],
119
+ hints: plan[:hints] || {} }
120
+ token = plan[:timing_blocker] ? nil : verifier.generate(envelope, purpose: "munawaba.#{operation}")
121
+ preview = Preview.new(token: token, projection: plan[:slots], conflicts: plan[:conflicts],
122
+ details: plan[:details].merge(operation: operation, blockers: plan[:blockers] || []))
123
+ Result.new(status: 200, record: subject, errors: [], preview: preview)
124
+ end
125
+
126
+ def schedule
127
+ subject.is_a?(Schedule) ? subject : subject.is_a?(Shift) ? subject.schedule : nil
128
+ end
129
+
130
+ def candidate_schedules
131
+ membership_ids = ScheduleMembership.where(person_id: subject.id).pluck(:schedule_id)
132
+ override_ids = ShiftOverride.joins(shift: :schedule).where(replacement_person_id: subject.id, ended_at: nil)
133
+ .where(munawaba_shifts: { canceled_at: nil }).where(munawaba_schedules: { state: %w[
134
+ scheduled active
135
+ ] })
136
+ .where("munawaba_shifts.coverage_revision = munawaba_schedules.coverage_revision").pluck("munawaba_shifts.schedule_id")
137
+ (membership_ids + override_ids).uniq.sort
138
+ end
139
+
140
+ def discover
141
+ schedules = operation == "deactivate" ? candidate_schedules : [schedule&.id].compact
142
+ memberships = ScheduleMembership.where(schedule_id: schedules)
143
+ shifts = Shift.where(schedule_id: schedules,
144
+ canceled_at: nil).where("coverage_revision = (SELECT coverage_revision FROM munawaba_schedules WHERE munawaba_schedules.id = munawaba_shifts.schedule_id)")
145
+ overrides = ShiftOverride.where(shift_id: shifts.select(:id), ended_at: nil)
146
+ people = memberships.pluck(:person_id) + shifts.pluck(:base_person_id,
147
+ :effective_person_id).flatten + overrides.pluck(
148
+ :previous_person_id, :replacement_person_id
149
+ ).flatten
150
+ people += Array(attributes[:person_ids]).reject(&:blank?).map { |id| Integer(id) }
151
+ people << Integer(attributes[:person_id]) if attributes[:person_id].present?
152
+ people << subject.id if subject.is_a?(Person)
153
+ { people: people.compact.uniq.sort, schedules: schedules, memberships: memberships.pluck(:id).sort,
154
+ shifts: shifts.pluck(:id).sort, overrides: overrides.pluck(:id).sort,
155
+ deliveries: NotificationDelivery.where(schedule_id: schedules, status: %w[pending enqueued processing]).pluck(:id).sort }
156
+ end
157
+
158
+ def lock_rows(ids)
159
+ Person.where(id: ids[:people]).order(:id).lock("FOR NO KEY UPDATE").load
160
+ Schedule.where(id: ids[:schedules]).order(:id).lock("FOR NO KEY UPDATE").load
161
+ # Capture one time after locking people and schedules, before child rows.
162
+ now = Canonical.time(Time.current)
163
+ ScheduleMembership.where(id: ids[:memberships]).order(:id).lock("FOR UPDATE").load
164
+ shifts = Shift.where(id: ids[:shifts]).order(:id).lock("FOR NO KEY UPDATE").to_a
165
+ ShiftOverride.where(id: ids[:overrides]).order(:id).lock("FOR NO KEY UPDATE").load
166
+ NotificationDelivery.where(id: ids[:deliveries]).order(:id).lock("FOR NO KEY UPDATE").load
167
+ [now, shifts]
168
+ end
169
+
170
+ def complete_discovery?(locked, current)
171
+ current.all? { |kind, ids| (ids - locked.fetch(kind)).empty? }
172
+ end
173
+
174
+ def roster_for(item)
175
+ ScheduleMembership.where(schedule_id: item.id).order(:position, :id).pluck(:person_id)
176
+ end
177
+
178
+ def order_for(item, required: false)
179
+ ids = attributes.key?(:person_ids) ? Array(attributes[:person_ids]).reject(&:blank?).map { |id|
180
+ Integer(id)
181
+ } : roster_for(item)
182
+ validate_order!(ids, required: required)
183
+ ids
184
+ end
185
+
186
+ def validate_order!(ids, required:)
187
+ raise Invalid, "Choose at least one active person and make Next explicit." if required && ids.empty?
188
+ raise Invalid, "A rotation cannot contain a person twice." unless ids.uniq == ids
189
+ raise Invalid,
190
+ "A rotation can contain at most #{Defaults::MAXIMUM_SCHEDULE_MEMBERS} people." if ids.length > Defaults::MAXIMUM_SCHEDULE_MEMBERS
191
+ raise Invalid, "Only active people can receive new assignments." unless Person.where(id: ids,
192
+ active: true).count == ids.length
193
+ end
194
+
195
+ def prepare(item, now, locked_shifts)
196
+ Shifts::NormalizeFutureTiming.prepare(schedule: item, now: now, shifts: locked_shifts&.select { |row|
197
+ row.schedule_id == item.id
198
+ })
199
+ end
200
+
201
+ def build(now:, envelope: nil, locked_shifts: nil)
202
+ case operation
203
+ when "activate", "resume" then build_run(now, envelope)
204
+ when "rotation" then build_rotation(now, locked_shifts)
205
+ when "override", "revoke", "restore_to_base" then build_override(now, locked_shifts)
206
+ when "pause", "cancel_scheduled" then build_lifecycle(now, locked_shifts)
207
+ when "deactivate" then build_deactivation(now, locked_shifts)
208
+ else raise Invalid, "Unknown command."
209
+ end
210
+ end
211
+
212
+ def evidence(expected, proposal, conflicts, extra = nil)
213
+ [expected, Canonical.digest(proposal), extra, Canonical.digest(conflicts)]
214
+ end
215
+
216
+ def build_run(now, envelope)
217
+ item = schedule
218
+ required_state = operation == "activate" ? "draft" : "paused"
219
+ raise Invalid, "The schedule must be #{required_state} before #{operation}." unless item.state == required_state
220
+
221
+ ids = order_for(item, required: true)
222
+ raise Invalid, "Save the rotation before activation." if operation == "activate" && ids != roster_for(item)
223
+
224
+ calculator = Timing::BoundaryCalculator.new(item)
225
+ hints = envelope ? envelope.fetch("hints") : {}
226
+ if operation == "activate"
227
+ kind = calculator.boundary(0).resolved_at > now ? "future_activation" : "immediate_activation"
228
+ boundary = kind == "future_activation" ? 0 : calculator.slot_at(now)
229
+ fixed = kind == "immediate_activation" ? (hints["conflict_check_at"] ? Canonical.from_micros(hints["conflict_check_at"]) : now) : nil
230
+ raise Stale if envelope && (hints["kind"] != kind || hints["boundary_index"] != boundary)
231
+ else
232
+ kind = "resume"
233
+ boundary = attributes[:boundary_index].present? ? Integer(attributes[:boundary_index]) : calculator.selectable_boundaries(now: now).first&.index
234
+ raise Invalid, "Choose an available handoff to resume coverage." if boundary.nil?
235
+
236
+ fixed = nil
237
+ end
238
+ boundary_at = calculator.boundary(boundary).resolved_at
239
+ if kind != "immediate_activation"
240
+ raise(envelope ? Stale : Invalid,
241
+ "The selected start is outside the selectable future window.") unless boundary_at >= now && boundary_at <= now + Munawaba.config.calendar_future_limit
242
+ end
243
+ plan = Shifts::ProjectionPlan.new(schedule: item, operation_kind: kind, now: now, person_ids: ids,
244
+ first_boundary: boundary, target_boundary: hints["target_boundary"], conflict_check_at: fixed)
245
+ if envelope
246
+ floor = Shifts::ProjectionPlan.target(calculator, boundary, now + Munawaba.config.calendar_future_limit)
247
+ raise Stale unless plan.target_boundary >= floor
248
+
249
+ plan.persistence_slots(now: now)
250
+ end
251
+ expected = item.slice(:lock_version, :lifecycle_revision, :coverage_revision, :rotation_revision)
252
+ proposal = if operation == "activate"
253
+ [item.id, kind == "immediate_activation" ? "immediate" : "future", boundary, fixed && Canonical.micros(fixed),
254
+ ids, plan.target_boundary, plan.digest]
255
+ else
256
+ [item.id, boundary, ids, plan.target_boundary, plan.digest]
257
+ end
258
+ conflicts = Conflicts::Finder.call(slots: plan.slots)
259
+ { evidence: evidence(expected, proposal, conflicts, plan.digest), slots: plan.slots,
260
+ conflicts: conflicts, plan: plan, person_ids: ids, hints: { kind: kind, boundary_index: boundary, target_boundary: plan.target_boundary, conflict_check_at: fixed && Canonical.micros(fixed) },
261
+ details: { state: plan.state, ordered_person_ids: ids, boundary_index: boundary, generated_through_boundary: plan.target_boundary, resolved_boundaries: plan.boundaries } }
262
+ end
263
+
264
+ def temporal_class(slot, now)
265
+ return "completed" if slot[:ends_at] <= now
266
+
267
+ slot[:starts_at] > now ? "future" : "current"
268
+ end
269
+
270
+ def effective_boundary(item, slots, now)
271
+ case item.state
272
+ when "active"
273
+ current = slots.find { |slot| temporal_class(slot, now) == "current" }
274
+ raise Invalid, "Projection maintenance is required to establish current coverage." unless current
275
+
276
+ [current[:boundary_index] + 1, current[:ends_at]]
277
+ when "scheduled" then [item.coverage_start_boundary, item.coverage_starts_at]
278
+ else [nil, nil]
279
+ end
280
+ end
281
+
282
+ def regenerated(item, prepared, ids, boundary, instant, now)
283
+ return [] if boundary.nil?
284
+
285
+ calculator = Timing::BoundaryCalculator.new(item)
286
+ target = [item.generated_through_boundary || boundary,
287
+ Shifts::ProjectionPlan.target(calculator, item.coverage_start_boundary,
288
+ now + Defaults::SHIFT_GENERATION_HORIZON)].max
289
+ existing = prepared.slots.index_by { |slot| slot[:boundary_index] }
290
+ active_overrides = ShiftOverride.where(shift_id: prepared.rows.map(&:id), ended_at: nil).index_by(&:shift_id)
291
+ (boundary..target).map do |index|
292
+ old = existing[index]
293
+ base = ids[(index - boundary) % ids.length]
294
+ override = old && active_overrides[old[:id]]
295
+ effective = override ? override.replacement_person_id : base
296
+ starts = index == boundary ? instant : calculator.boundary(index).resolved_at
297
+ ends = calculator.boundary(index + 1).resolved_at
298
+ raise Invalid, "Timing maintenance would create a non-positive shift." unless ends > starts
299
+
300
+ { id: old&.dig(:id), schedule_id: item.id, coverage_revision: item.coverage_revision, boundary_index: index,
301
+ starts_at: starts, ends_at: ends, base_person_id: base, effective_person_id: effective,
302
+ rotation_revision: item.rotation_revision + 1, assignment_version: old ? old[:assignment_version] + (old[:effective_person_id] == effective ? 0 : 1) : 1,
303
+ timing_version: old ? old[:timing_version] + (old[:starts_at] == starts && old[:ends_at] == ends ? 0 : 1) : 1,
304
+ active_override_id: override&.id }
305
+ end
306
+ end
307
+
308
+ def build_rotation(now, locked)
309
+ item = schedule
310
+ prepared = prepare(item, now, locked)
311
+ virtual = prepared.virtual_schedule
312
+ ids = order_for(item, required: %w[active scheduled].include?(virtual.state))
313
+ boundary, instant = effective_boundary(virtual, prepared.slots, now)
314
+ next_slot = prepared.slots.find { |slot| slot[:starts_at] > now }
315
+ next_persisted = next_slot && prepared.rows.find { |row| row.id == next_slot[:id] }
316
+ expected = item.slice(:lifecycle_revision, :coverage_revision, :rotation_revision,
317
+ :cadence, :time_zone, :anchor_local_date, :anchor_local_seconds)
318
+ expected.merge!("next_shift_id" => next_slot&.dig(:id), "next_shift_timing_version" => next_persisted&.timing_version)
319
+ slots = regenerated(virtual, prepared, ids, boundary, instant, now)
320
+ final_slots = (prepared.changes + slots).index_by { |slot| [slot[:schedule_id], slot[:boundary_index]] }.values
321
+ all_conflicts = Conflicts::Finder.call(slots: final_slots)
322
+ prepared.conflicts = all_conflicts
323
+ conflicts = conflicts_for(all_conflicts, slots)
324
+ extra = Canonical.digest([virtual.state, virtual.lifecycle_revision, normalized_slots(prepared.slots),
325
+ normalized_slots(slots)])
326
+ { evidence: evidence(expected, [item.id, boundary, ids], conflicts, extra), slots: slots, conflicts: conflicts,
327
+ prepared: prepared, person_ids: ids, before_ids: roster_for(item), boundary: boundary, instant: instant,
328
+ details: { state: virtual.state, ordered_person_ids: ids, boundary_index: boundary, effective_at: instant,
329
+ generated_through_boundary: slots.last&.dig(:boundary_index) || virtual.generated_through_boundary } }
330
+ end
331
+
332
+ def build_override(now, locked)
333
+ item = schedule
334
+ prepared = prepare(item, now, locked)
335
+ slot = prepared.slots.find { |entry| entry[:id] == subject.id }
336
+ raise Invalid,
337
+ "Canceled and historical runs cannot be overridden." unless slot && subject.coverage_revision == item.coverage_revision
338
+
339
+ klass = temporal_class(slot, now)
340
+ raise Invalid, "Completed shifts cannot be changed." if klass == "completed"
341
+
342
+ active_override = ShiftOverride.find_by(shift_id: subject.id, ended_at: nil)
343
+ expected = { "expected_coverage_revision" => subject.coverage_revision, "expected_assignment_version" => subject.assignment_version,
344
+ "expected_timing_version" => subject.timing_version, "expected_base_person_id" => subject.base_person_id,
345
+ "expected_shift_temporal_class" => klass }
346
+ if operation == "override"
347
+ person_id = Integer(attributes[:person_id])
348
+ raise Invalid, "Choose a different active person." unless Person.where(id: person_id,
349
+ active: true).exists? && person_id != slot[:effective_person_id]
350
+
351
+ reason = attributes[:reason].to_s.strip.presence
352
+ raise Invalid, "Reason must be no longer than 1,000 characters." if reason && reason.length > 1000
353
+
354
+ proposal = [subject.id, "apply", person_id, reason]
355
+ else
356
+ raise Invalid, "This shift has no override to end." unless active_override
357
+
358
+ required_class = operation == "revoke" ? "future" : "current"
359
+ raise Invalid, "This action requires a #{required_class} shift." unless klass == required_class
360
+
361
+ person_id = slot[:base_person_id]
362
+ raise Invalid, "The base person is inactive. Choose an active replacement." unless Person.where(id: person_id,
363
+ active: true).exists?
364
+
365
+ proposal = [subject.id, operation, active_override.id]
366
+ end
367
+ proposed = slot.merge(effective_person_id: person_id,
368
+ assignment_version: slot[:assignment_version] + (slot[:effective_person_id] == person_id ? 0 : 1))
369
+ final_slots = (prepared.changes + [proposed]).index_by { |entry|
370
+ [entry[:schedule_id], entry[:boundary_index]]
371
+ }.values
372
+ all_conflicts = Conflicts::Finder.call(slots: final_slots)
373
+ prepared.conflicts = all_conflicts
374
+ conflicts = slot[:effective_person_id] == person_id ? [] : conflicts_for(all_conflicts, [proposed])
375
+ extra = Canonical.digest([prepared.virtual_schedule.state, prepared.virtual_schedule.lifecycle_revision,
376
+ normalized_slots(prepared.slots), active_override&.id])
377
+ { evidence: evidence(expected, proposal, conflicts, extra), slots: [proposed], conflicts: conflicts,
378
+ prepared: prepared, active_override: active_override, previous_person_id: slot[:effective_person_id], person_id: person_id, reason: reason,
379
+ details: { state: prepared.virtual_schedule.state, temporal_class: klass, base_person_id: slot[:base_person_id], person_id: person_id } }
380
+ end
381
+
382
+ def build_lifecycle(now, locked)
383
+ item = schedule
384
+ prepared = prepare(item, now, locked)
385
+ virtual = prepared.virtual_schedule
386
+ if operation == "pause"
387
+ raise Invalid, "Only current active coverage can be paused." unless virtual.state == "active"
388
+
389
+ current = prepared.slots.find { |slot| temporal_class(slot, now) == "current" }
390
+ raise Invalid, "Projection maintenance is required to find current coverage." unless current
391
+
392
+ persisted = prepared.rows.find { |row| row.id == current[:id] }
393
+ expected = { "expected_lifecycle_revision" => item.lifecycle_revision,
394
+ "expected_current_shift_id" => current[:id], "expected_current_shift_timing_version" => persisted.timing_version }
395
+ target = current
396
+ else
397
+ raise Stale unless item.state == "scheduled" && virtual.state == "scheduled" && !prepared.clipping
398
+
399
+ target = prepared.slots.first
400
+ persisted = prepared.rows.first
401
+ expected = { "expected_lifecycle_revision" => item.lifecycle_revision, "expected_coverage_revision" => item.coverage_revision,
402
+ "expected_first_shift_id" => persisted.id, "expected_first_shift_timing_version" => persisted.timing_version }
403
+ end
404
+ prepared.conflicts = Conflicts::Finder.call(slots: prepared.changes) if operation == "pause" && prepared.changes.any?
405
+ # Cancel future coverage using its saved boundary, without applying new timezone rules.
406
+ extra = operation == "pause" ? [virtual.state, target[:id], Canonical.micros(target[:ends_at]),
407
+ target[:timing_version]] : [virtual.state, target[:id],
408
+ virtual.coverage_start_boundary]
409
+ { evidence: evidence(expected, [item.id, operation], [], extra), slots: [target], conflicts: [],
410
+ prepared: prepared, target: target, details: { state: virtual.state, effective_at: operation == "pause" ? target[:ends_at] : now } }
411
+ end
412
+
413
+ def conflicts_for(conflicts, slots)
414
+ identities = slots.map { |slot| [slot[:schedule_id], slot[:coverage_revision], slot[:boundary_index]] }.to_set
415
+ conflicts.select { |entry|
416
+ identities.include?([entry[0], entry[2], entry[3]]) || identities.include?([entry[4], entry[6], entry[7]])
417
+ }
418
+ end
419
+
420
+ def normalized_slots(slots)
421
+ override_ids = ShiftOverride.where(shift_id: slots.filter_map { |slot|
422
+ slot[:id]
423
+ }, ended_at: nil).pluck(:shift_id, :id).to_h
424
+ slots.sort_by { |slot| [slot[:boundary_index], slot[:id] || -1] }.map do |slot|
425
+ [slot[:id], slot[:coverage_revision], slot[:boundary_index], Canonical.micros(slot[:starts_at]), Canonical.micros(slot[:ends_at]),
426
+ slot[:base_person_id], slot[:effective_person_id], slot[:rotation_revision], slot[:assignment_version], slot[:timing_version],
427
+ slot.key?(:active_override_id) ? slot[:active_override_id] : override_ids[slot[:id]]]
428
+ end
429
+ end
430
+
431
+ def shift_ref(slot)
432
+ return nil unless slot
433
+
434
+ [slot[:id], slot[:coverage_revision], slot[:boundary_index], Canonical.micros(slot[:starts_at]),
435
+ Canonical.micros(slot[:ends_at]), slot[:timing_version], slot[:base_person_id], slot[:effective_person_id], slot[:assignment_version]]
436
+ end
437
+
438
+ def build_deactivation(now, locked)
439
+ raise Invalid, "This person is already inactive." unless subject.active?
440
+
441
+ schedule_plans = []
442
+ canonical_plans = []
443
+ blockers = []
444
+ Schedule.where(id: candidate_schedules).order(:id).each do |item|
445
+ prepared = prepare(item, now, locked)
446
+ virtual = prepared.virtual_schedule
447
+ memberships = ScheduleMembership.where(schedule_id: item.id).order(:position, :id).includes(:person).to_a
448
+ before_ids = memberships.map(&:person_id)
449
+ member = before_ids.include?(subject.id)
450
+ active_overrides = ShiftOverride.where(shift_id: prepared.rows.map(&:id), replacement_person_id: subject.id, ended_at: nil).order(
451
+ :shift_id, :id
452
+ ).to_a
453
+ override_blockers = active_overrides.filter_map do |override|
454
+ slot = prepared.slots.find { |entry| entry[:id] == override.shift_id }
455
+ next unless slot && temporal_class(slot, now) == "future"
456
+
457
+ [override.id, slot[:id], slot[:coverage_revision], slot[:boundary_index], override.replacement_person_id,
458
+ slot[:assignment_version], slot[:timing_version], Canonical.micros(slot[:starts_at])]
459
+ end
460
+ local_blockers = []
461
+ local_blockers << "Replace or revoke this person's future overrides." if override_blockers.any?
462
+ local_blockers << "Timing maintenance must finish before deactivation." if prepared.clipping
463
+ ids = before_ids - [subject.id]
464
+ boundary, instant = member ? effective_boundary(virtual, prepared.slots, now) : [nil, nil]
465
+ empty = member && %w[active scheduled].include?(virtual.state) && ids.empty?
466
+ local_blockers << "An active or scheduled rotation needs at least one member." if empty
467
+ if member && boundary && ids.any?
468
+ old_next_index = (boundary - virtual.rotation_effective_boundary) % before_ids.length
469
+ cycle = before_ids.rotate(old_next_index)
470
+ successor = cycle.find { |id| id != subject.id && Person.find(id).active? }
471
+ raise Invalid, "The surviving rotation contains an inactive person." unless successor
472
+
473
+ ids = ids.rotate(ids.index(successor))
474
+ else
475
+ successor = nil
476
+ end
477
+ regenerated_slots = member && boundary && !empty ? regenerated(virtual, prepared, ids, boundary, instant,
478
+ now) : []
479
+ all_slots = (prepared.slots.index_by { |slot| slot[:boundary_index] }.merge(regenerated_slots.index_by { |slot|
480
+ slot[:boundary_index]
481
+ })).values.sort_by { |slot| slot[:boundary_index] }
482
+ current = all_slots.find { |slot| temporal_class(slot, now) == "current" }
483
+ next_slot = all_slots.find { |slot| slot[:starts_at] > now }
484
+ plan = { schedule: item, prepared: prepared, member: member, before_ids: before_ids, person_ids: ids, boundary: boundary,
485
+ instant: instant, successor: successor, slots: regenerated_slots, blockers: local_blockers }
486
+ schedule_plans << plan
487
+ canonical_plans << [item.id, member ? "deactivation" : "normalization_only", member, virtual.state, virtual.lifecycle_revision,
488
+ virtual.coverage_revision, virtual.rotation_revision + (member ? 1 : 0), regenerated_slots.last&.dig(:boundary_index) || virtual.generated_through_boundary,
489
+ memberships.map { |membership|
490
+ [membership.id, membership.person_id, membership.position, membership.person.deactivated_at && Canonical.micros(membership.person.deactivated_at)]
491
+ },
492
+ shift_ref(current), shift_ref(next_slot), Canonical.digest(normalized_slots(all_slots)), override_blockers, ids, successor,
493
+ boundary, instant && Canonical.micros(instant), empty]
494
+ blockers.concat(local_blockers.map { |text| "#{item.name}: #{text}" })
495
+ end
496
+ slots = schedule_plans.flat_map { |plan|
497
+ (plan[:slots] + plan[:prepared].changes).uniq { |slot|
498
+ [slot[:schedule_id], slot[:boundary_index]]
499
+ }
500
+ }
501
+ conflicts = Conflicts::Finder.call(slots: slots)
502
+ schedule_plans.each do |entry|
503
+ entry[:prepared].conflicts = conflicts.select { |conflict|
504
+ conflict[0] == entry[:schedule].id || conflict[4] == entry[:schedule].id
505
+ }
506
+ end
507
+ digest = Canonical.digest([1, subject.id, canonical_plans])
508
+ expected = { "expected_person_lock_version" => subject.lock_version }
509
+ { evidence: evidence(expected, [subject.id], conflicts, digest), slots: slots, conflicts: conflicts,
510
+ schedule_plans: schedule_plans, blockers: blockers,
511
+ timing_blocker: schedule_plans.any? { |entry| entry[:prepared].clipping },
512
+ details: { schedule_plans: schedule_plans.map { |plan|
513
+ { schedule_id: plan[:schedule].id, name: plan[:schedule].name,
514
+ before_person_ids: plan[:before_ids], person_ids: plan[:person_ids], successor_person_id: plan[:successor], effective_boundary: plan[:boundary],
515
+ effective_at: plan[:instant], blockers: plan[:blockers], projection: plan[:slots].first(6) }
516
+ } } }
517
+ end
518
+
519
+ def audit(event, schedule: nil, person: nil, shift: nil, metadata:, now:)
520
+ Audit::Recorder.record!(event_type: event, actor: actor,
521
+ operation_id: @operation_id, occurred_at: now, schedule: schedule, person: person, shift: shift, metadata: metadata)
522
+ end
523
+
524
+ def notifications(item, now, **options)
525
+ Notifications::Planner.call(schedule: item, now: now, **options)
526
+ end
527
+
528
+ def rewrite_memberships(item, ids)
529
+ memberships = ScheduleMembership.where(schedule_id: item.id).order(:position, :id).to_a
530
+ offset = [memberships.map(&:position).max || 0, ids.length].max + memberships.length + 1
531
+ memberships.each_with_index { |membership, index| membership.update!(position: offset + index) }
532
+ memberships.reject { |membership| ids.include?(membership.person_id) }.each(&:destroy!)
533
+ existing = memberships.index_by(&:person_id)
534
+ ids.each_with_index do |id, position|
535
+ membership = existing[id]
536
+ membership ? membership.update!(position: position) : ScheduleMembership.create!(schedule_id: item.id,
537
+ person_id: id, position: position)
538
+ end
539
+ end
540
+
541
+ def apply_plan(plan, now:)
542
+ case operation
543
+ when "activate", "resume" then apply_run(plan, now)
544
+ when "rotation"
545
+ Shifts::NormalizeFutureTiming.apply!(plan[:prepared], now: now, operation_id: @operation_id, defer: true)
546
+ apply_rotation(plan, now)
547
+ when "override", "revoke", "restore_to_base"
548
+ Shifts::NormalizeFutureTiming.apply!(plan[:prepared], now: now, operation_id: @operation_id, defer: true)
549
+ apply_override(plan, now)
550
+ when "pause", "cancel_scheduled" then apply_lifecycle(plan, now)
551
+ when "deactivate" then apply_deactivation(plan, now)
552
+ end
553
+ end
554
+
555
+ def lifecycle_metadata(item, first, before)
556
+ { state_before: before, state_after: item.state, coverage_starts_at: item.coverage_starts_at,
557
+ next_person_id: first.base_person_id }
558
+ end
559
+
560
+ def apply_run(plan, now)
561
+ item = schedule
562
+ projection = plan[:plan]
563
+ before = item.state
564
+ rewrite_memberships(item, plan[:person_ids]) if operation == "resume"
565
+ rows = Shift.persist_projection!(schedule: item, slots: projection.persistence_slots(now: now), now: now)
566
+ first = rows.first
567
+ item.assign_attributes(state: projection.state, coverage_revision: projection.coverage_revision, rotation_revision: projection.rotation_revision,
568
+ coverage_start_boundary: projection.first_boundary, coverage_starts_at: first.starts_at,
569
+ generated_through_boundary: projection.target_boundary, rotation_effective_boundary: projection.first_boundary,
570
+ lifecycle_revision: item.lifecycle_revision + 1)
571
+ item.first_activated_at ||= first.starts_at if item.state == "active"
572
+ item.save!
573
+ notifications(item, now, operation: operation, shifts: rows)
574
+ event = item.state == "scheduled" ? (operation == "activate" ? "schedule.activation_scheduled" : "schedule.resume_scheduled") : (operation == "activate" ? "schedule.activated" : "schedule.resumed")
575
+ audit(event, schedule: item, shift: first, now: now,
576
+ metadata: lifecycle_metadata(item, first, before))
577
+ schedule_id, revision = item.id, item.coverage_revision
578
+ ActiveRecord.after_all_transactions_commit do
579
+ begin
580
+ job = MaintainProjectionJob.perform_later(schedule_id, revision)
581
+ ActiveSupport::Notifications.instrument("projection.enqueue_failed.munawaba",
582
+ schedule_id: schedule_id) unless job && job.successfully_enqueued?
583
+ rescue StandardError
584
+ ActiveSupport::Notifications.instrument("projection.enqueue_failed.munawaba", schedule_id: schedule_id)
585
+ end
586
+ end
587
+ end
588
+
589
+ def apply_rotation(plan, now, deactivated_person: nil)
590
+ item = plan[:prepared].schedule
591
+ before_next = plan[:boundary] && begin
592
+ old = plan[:prepared].slots.find { |slot| slot[:boundary_index] == plan[:boundary] }
593
+ old&.dig(:base_person_id)
594
+ end
595
+ rewrite_memberships(item, plan[:person_ids])
596
+ rows = Shift.persist_projection!(schedule: item, slots: plan[:slots], now: now)
597
+ item.rotation_revision += 1
598
+ item.rotation_effective_boundary = plan[:boundary]
599
+ item.generated_through_boundary = rows.last.boundary_index if rows.any?
600
+ item.save!
601
+ Shifts::NormalizeFutureTiming.finalize!(plan[:prepared], now: now, operation_id: @operation_id)
602
+ notifications(item, now, operation: "rotation", shifts: rows, previous_person_id: before_next)
603
+ metadata = { effective_at: plan[:instant], before_person_ids: plan[:before_ids],
604
+ after_person_ids: plan[:person_ids], next_person_id: plan[:boundary] && plan[:person_ids].first }
605
+ metadata[:removed_person_id] = deactivated_person.id if deactivated_person
606
+ audit(deactivated_person ? "rotation.changed_by_deactivation" : "rotation.replaced", schedule: item,
607
+ person: deactivated_person, now: now, metadata: metadata)
608
+ end
609
+
610
+ def apply_override(plan, now)
611
+ item = schedule
612
+ subject.reload
613
+ ended = plan[:active_override]
614
+ if ended
615
+ ended.update!(ended_at: now,
616
+ end_reason: operation == "override" ? "superseded" : operation == "revoke" ? "revoked" : "restored_to_base")
617
+ end
618
+ fresh = if operation == "override"
619
+ ShiftOverride.create!(shift: subject, previous_person_id: plan[:previous_person_id],
620
+ replacement_person_id: plan[:person_id], reason: plan[:reason])
621
+ end
622
+ slot = plan[:slots].first
623
+ subject.update!(effective_person_id: slot[:effective_person_id],
624
+ assignment_version: slot[:assignment_version]) if subject.effective_person_id != slot[:effective_person_id]
625
+ Shifts::NormalizeFutureTiming.finalize!(plan[:prepared], now: now, operation_id: @operation_id)
626
+ notifications(item, now, operation: operation, shifts: [subject],
627
+ previous_person_id: plan[:previous_person_id], override: fresh, ended_override: ended)
628
+ metadata = { previous_person_id: plan[:previous_person_id], new_person_id: plan[:person_id] }
629
+ if fresh && ended
630
+ event = "override.superseded"
631
+ metadata.merge!(ended_override_id: ended.id, new_override_id: fresh.id)
632
+ elsif fresh
633
+ event = "override.created"
634
+ metadata[:override_id] = fresh.id
635
+ else
636
+ event = operation == "revoke" ? "override.revoked" : "override.restored_to_base"
637
+ metadata[:override_id] = ended.id
638
+ end
639
+ audit(event, schedule: item, shift: subject, person: Person.find(plan[:person_id]), now: now, metadata: metadata)
640
+ end
641
+
642
+ def cancel_rows(item, rows, reason, now)
643
+ rows.each do |row|
644
+ override = ShiftOverride.find_by(shift_id: row.id, ended_at: nil)
645
+ if override
646
+ override.update!(ended_at: now, end_reason: "shift_canceled")
647
+ audit("override.ended_by_shift_cancellation", schedule: item, shift: row, person: row.effective_person, now: now, metadata: {
648
+ override_id: override.id, cancellation_reason: reason
649
+ })
650
+ end
651
+ row.update!(canceled_at: now, cancellation_reason: reason)
652
+ end
653
+ notifications(item, now, operation: "cancel", shifts: rows)
654
+ end
655
+
656
+ def apply_lifecycle(plan, now)
657
+ item = schedule
658
+ Shifts::NormalizeFutureTiming.apply!(plan[:prepared], now: now, operation_id: @operation_id, defer: true) if operation == "pause"
659
+ before = item.state
660
+ target = Shift.find(plan[:target][:id])
661
+ if operation == "pause"
662
+ rows = plan[:prepared].rows.select { |row| row.id != target.id && row.starts_at >= target.ends_at }
663
+ cancel_rows(item, rows, "pause", now)
664
+ Shifts::NormalizeFutureTiming.finalize!(plan[:prepared], now: now, operation_id: @operation_id)
665
+ item.update!(state: "pausing", pause_effective_at: target.ends_at, rotation_effective_boundary: nil,
666
+ generated_through_boundary: target.boundary_index, lifecycle_revision: item.lifecycle_revision + 1)
667
+ audit("schedule.pause_requested", schedule: item, shift: target, now: now, metadata: {
668
+ state_before: before, state_after: item.state, pause_effective_at: target.ends_at,
669
+ canceled_shift_count: rows.length
670
+ })
671
+ else
672
+ rows = plan[:prepared].rows
673
+ cancel_rows(item, rows, "scheduled_run_canceled", now)
674
+ item.update!(state: item.first_activated_at ? "paused" : "draft", coverage_start_boundary: nil, coverage_starts_at: nil,
675
+ generated_through_boundary: nil, rotation_effective_boundary: nil, pause_effective_at: nil, lifecycle_revision: item.lifecycle_revision + 1)
676
+ audit("schedule.scheduled_start_canceled", schedule: item, shift: target, now: now, metadata: {
677
+ state_before: before, state_after: item.state, canceled_shift_count: rows.length
678
+ })
679
+ end
680
+ end
681
+
682
+ def apply_deactivation(plan, now)
683
+ plan[:schedule_plans].each do |schedule_plan|
684
+ Shifts::NormalizeFutureTiming.apply!(schedule_plan[:prepared], now: now, operation_id: @operation_id, defer: schedule_plan[:member])
685
+ next unless schedule_plan[:member]
686
+
687
+ apply_rotation(schedule_plan, now, deactivated_person: subject)
688
+ end
689
+ subject.update!(active: false, deactivated_at: now)
690
+ ids = plan[:schedule_plans].map { |schedule_plan| schedule_plan[:schedule].id }.sort
691
+ audit("person.deactivated", person: subject, now: now,
692
+ metadata: { affected_schedule_count: ids.length, affected_schedule_ids: ids.first(100) })
693
+ end
694
+
695
+ def simple_change
696
+ ActiveRecord::Base.transaction(requires_new: true) do
697
+ subject.lock!("FOR NO KEY UPDATE") if subject.persisted?
698
+ now = Canonical.time(Time.current)
699
+ if operation.start_with?("update_")
700
+ raise Stale unless attributes.key?(:lock_version) && Integer(attributes[:lock_version]) == subject.lock_version
701
+ end
702
+ case operation
703
+ when "create_person", "update_person"
704
+ subject.assign_attributes(attributes.slice(:name, :email, :slack_member_id))
705
+ fields = subject.changes.keys.sort
706
+ subject.save!
707
+ if operation == "create_person"
708
+ audit("person.created", person: subject, now: now,
709
+ metadata: { active: subject.active?, slack_member_configured: subject.slack_member_id.present? })
710
+ else
711
+ audit("person.updated", person: subject, now: now, metadata: { changed_fields: fields })
712
+ end
713
+ when "reactivate"
714
+ raise Invalid, "This person is already active." if subject.active?
715
+
716
+ subject.update!(active: true, deactivated_at: nil)
717
+ audit("person.reactivated", person: subject, now: now, metadata: { reactivated_at: Canonical.instant(now) })
718
+ when "create_schedule", "update_schedule"
719
+ keys = %i[name cadence time_zone anchor_local_date anchor_local_seconds]
720
+ before = subject.attributes.slice(*keys.map(&:to_s))
721
+ subject.assign_attributes(attributes.slice(*keys))
722
+ changed = subject.changes.keys & keys.map(&:to_s)
723
+ if operation == "update_schedule" && (changed - ["name"]).any? && (subject.state != "draft" || subject.first_activated_at)
724
+ raise Invalid, "Timing can only change on a draft that has never started."
725
+ end
726
+
727
+ subject.save!
728
+ after = subject.attributes.slice(*keys.map(&:to_s))
729
+ [before, after].each { |values| values["anchor_local_date"] = values["anchor_local_date"]&.iso8601 }
730
+ if operation == "create_schedule"
731
+ audit("schedule.created", schedule: subject, now: now, metadata: after.merge("state" => subject.state))
732
+ else
733
+ audit("schedule.updated", schedule: subject, now: now,
734
+ metadata: { changed_fields: changed.sort, before: before.slice(*changed), after: after.slice(*changed) })
735
+ end
736
+ end
737
+ end
738
+ Result.new(status: 303, record: subject, errors: [])
739
+ end
740
+ end
741
+ end