Module: PlanMyStuff::IssueExtractions::Approvals

Included in:
PlanMyStuff::Issue
Defined in:
lib/plan_my_stuff/issue_extractions/approvals.rb

Instance Method Summary collapse

Instance Method Details

#approvals_required?Boolean

Returns true when at least one approver is required on this issue.

Returns:

  • (Boolean)

    true when at least one approver is required on this issue



23
24
25
# File 'lib/plan_my_stuff/issue_extractions/approvals.rb', line 23

def approvals_required?
  approvers.present?
end

#approve!(user:) ⇒ PlanMyStuff::Approval

Flips the caller’s approval to approved from any other state (pending or rejected). Only the approver themselves may call this. Fires plan_my_stuff.issue.approval_granted and, when this flip completes the approval set, plan_my_stuff.issue.all_approved.

Parameters:

  • user (Object, Integer)

    actor; must resolve to an approver

Returns:

Raises:



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/plan_my_stuff/issue_extractions/approvals.rb', line 93

def approve!(user:)
  actor_id = resolve_actor_id!(user)

  just_approved, was_fully_approved = modify_approvals! do |current|
    approval = current.find { |a| a.user_id == actor_id }
    raise(PlanMyStuff::ValidationError, "User #{actor_id} is not in the approvers list") if approval.nil?
    raise(PlanMyStuff::ValidationError, "User #{actor_id} has already approved") if approval.approved?

    approval.status = 'approved'
    approval.approved_at = Time.current
    approval.rejected_at = nil
    [current, approval]
  end

  finish_state_change(:approval_granted, just_approved, user: user, was_fully_approved: was_fully_approved)
  just_approved
end

#approversArray<PlanMyStuff::Approval>

Returns all required approvers (pending + approved + rejected).

Returns:



7
8
9
# File 'lib/plan_my_stuff/issue_extractions/approvals.rb', line 7

def approvers
  .approvals
end

#fully_approved?Boolean

Returns true when approvers are required AND every approver has approved. A single rejection blocks this gate until the approver revokes.

Returns:

  • (Boolean)

    true when approvers are required AND every approver has approved. A single rejection blocks this gate until the approver revokes.



29
30
31
# File 'lib/plan_my_stuff/issue_extractions/approvals.rb', line 29

def fully_approved?
  approvals_required? && approvers.all?(&:approved?)
end

#pending_approvalsArray<PlanMyStuff::Approval>

Returns approvers who have not yet acted (pending only; rejections are NOT pending – the approver has responded).

Returns:

  • (Array<PlanMyStuff::Approval>)

    approvers who have not yet acted (pending only; rejections are NOT pending – the approver has responded)



13
14
15
# File 'lib/plan_my_stuff/issue_extractions/approvals.rb', line 13

def pending_approvals
  approvers.select(&:pending?)
end

#reject!(user:) ⇒ PlanMyStuff::Approval

Flips the caller’s approval to rejected from any other state (pending or approved). Only the approver themselves may call this. Fires plan_my_stuff.issue.approval_rejected and, when this flip drops the issue out of fully_approved? (i.e. the caller was the last approved approver), plan_my_stuff.issue.approvals_invalidated (+trigger: :rejected+).

Parameters:

  • user (Object, Integer)

    actor; must resolve to an approver

Returns:

Raises:



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/plan_my_stuff/issue_extractions/approvals.rb', line 122

def reject!(user:)
  actor_id = resolve_actor_id!(user)

  just_rejected, was_fully_approved = modify_approvals! do |current|
    approval = current.find { |a| a.user_id == actor_id }
    raise(PlanMyStuff::ValidationError, "User #{actor_id} is not in the approvers list") if approval.nil?
    raise(PlanMyStuff::ValidationError, "User #{actor_id} has already rejected") if approval.rejected?

    approval.status = 'rejected'
    approval.rejected_at = Time.current
    approval.approved_at = nil
    [current, approval]
  end

  finish_state_change(
    :approval_rejected,
    just_rejected,
    user: user,
    was_fully_approved: was_fully_approved,
    trigger: :rejected,
  )
  just_rejected
end

#rejected_approvalsArray<PlanMyStuff::Approval>

Returns approvers who have rejected.

Returns:



18
19
20
# File 'lib/plan_my_stuff/issue_extractions/approvals.rb', line 18

def rejected_approvals
  approvers.select(&:rejected?)
end

#remove_approvers!(user_ids:, user: nil) ⇒ Array<PlanMyStuff::Approval>

Removes approvers from this issue’s required-approvals list. Only support users may call this. Removing a pending approver may flip the issue into fully_approved? (fires all_approved). Removing an approved approver fires no events (state does not flip). Removing the last approver never fires aggregate events (issue no longer has approvals_required?).

Parameters:

  • user_ids (Array<Integer>, Integer)
  • user (Object, nil) (defaults to: nil)

    actor; must be a support user

Returns:



70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/plan_my_stuff/issue_extractions/approvals.rb', line 70

def remove_approvers!(user_ids:, user: nil)
  guard_support!(user)
  ids = Array.wrap(user_ids).map(&:to_i)

  just_removed, was_fully_approved = modify_approvals! do |current|
    removed = current.select { |a| ids.include?(a.user_id) }
    [current - removed, removed]
  end

  emit_aggregate_events(was_fully_approved: was_fully_approved, trigger: nil, user: user)
  just_removed
end

#request_approvals!(user_ids:, user: nil) ⇒ Array<PlanMyStuff::Approval>

Adds approvers to this issue’s required-approvals list. Idempotent: users already present are no-ops. Only support users may call this.

Fires plan_my_stuff.issue.approval_requested when any user is newly added. Also fires plan_my_stuff.issue.approvals_invalidated (+trigger: :approver_added+) when the new approvers flip the issue out of a fully-approved state.

Parameters:

  • user_ids (Array<Integer>, Integer)
  • user (Object, nil) (defaults to: nil)

    actor; must be a support user

Returns:



45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/plan_my_stuff/issue_extractions/approvals.rb', line 45

def request_approvals!(user_ids:, user: nil)
  guard_support!(user)
  ids = Array.wrap(user_ids).map(&:to_i)

  just_added, was_fully_approved = modify_approvals! do |current|
    existing_ids = current.map(&:user_id)
    new_ids = ids - existing_ids
    added = new_ids.map { |id| PlanMyStuff::Approval.new(user_id: id, status: 'pending') }
    [current + added, added]
  end

  finish_request_approvals(just_added, user: user, was_fully_approved: was_fully_approved)
  just_added
end

#revoke_approval!(user:, target_user_id: nil) ⇒ PlanMyStuff::Approval

Flips an approved or rejected record back to pending. Approvers may revoke their own response; support users may revoke any approver’s response by passing target_user_id:. Non-support callers passing a target_user_id: that is not their own raise AuthorizationError.

Emits the granular event keyed off the source state: plan_my_stuff.issue.approval_revoked from approved, or plan_my_stuff.issue.rejection_revoked from rejected. When revoking an approval drops the issue out of fully_approved?, also fires plan_my_stuff.issue.approvals_invalidated (+trigger: :revoked+). Revoking a rejection cannot change fully_approved? (the issue was already gated), so no aggregate event fires.

Parameters:

  • user (Object, Integer)

    the caller

  • target_user_id (Integer, nil) (defaults to: nil)

    approver whose response should be revoked; defaults to the caller

Returns:

Raises:



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/plan_my_stuff/issue_extractions/approvals.rb', line 163

def revoke_approval!(user:, target_user_id: nil)
  actor_id = resolve_actor_id!(user)
  caller_is_support = PlanMyStuff::UserResolver.support?(PlanMyStuff::UserResolver.resolve(user))
  target_id = target_user_id&.to_i || actor_id

  if !caller_is_support && target_id != actor_id
    raise(PlanMyStuff::AuthorizationError, "Only support users may revoke another user's response")
  end

  revoked_from = nil
  just_revoked, was_fully_approved = modify_approvals! do |current|
    approval = current.find { |a| a.user_id == target_id }
    raise(PlanMyStuff::ValidationError, "User #{target_id} is not in the approvers list") if approval.nil?
    if approval.pending?
      raise(PlanMyStuff::ValidationError, "User #{target_id} has not responded -- nothing to revoke")
    end

    revoked_from = approval.status
    approval.status = 'pending'
    approval.approved_at = nil
    approval.rejected_at = nil
    [current, approval]
  end

  event = (revoked_from == 'approved') ? :approval_revoked : :rejection_revoked
  finish_state_change(
    event,
    just_revoked,
    user: user,
    was_fully_approved: was_fully_approved,
    trigger: (event == :approval_revoked) ? :revoked : nil,
  )
  just_revoked
end