Skip to content

Commit 9c84712

Browse files
OSCER-657: denial response form and task (#678)
## Ticket Step 2 of #657 ## Changes Add the `DenialResponseApplicationForm` model and route its submission into staff review. This is the member-submits → case-enters-review half of the denial-response flow; the staff approve/deny decisions land in the follow-up `denial-response-decisions` PR. - **`DenialResponseApplicationForm`** (`Strata::ApplicationForm`) — a `comment` text attribute plus optional `has_many_attached :supporting_documents`. - **`ReviewDenialResponseTask`** (`OscerTask`) — binds to its `DenialResponseApplicationForm` - **`CertificationBusinessProcess`** — new `review_denial_response` staff-task step and the `DenialResponseApplicationFormSubmitted` → `review_denial_response` transition. - **`MemberStatusService`** — the `review_denial_response` step maps to `pending_review`, like the other review steps. - Factories for the form and the review task. ## Context for reviewers Second PR in the denial-response stack -- broken up to prevent review fatigue. The design deliberately follows the existing application-form + review-task pattern (activity reports, exemptions) so the flow plugs into the certification business process the same way. Note the scope boundary: this PR adds **submission and routing into review only**. The form has no `flow_status` and there are no approve/deny transitions yet — those are added in the next PR, where they can be exercised end to end. Keeping them out here avoids untested or defensively-stubbed behavior; every line added in this PR is covered by a spec. ## Testing - New model specs for `DenialResponseApplicationForm` - New `ReviewDenialResponseTask` specs - New business-process example: submitting a denial response moves the case to `review_denial_response`, sets member status to `pending_review`, keeps the case open, and creates the review task. - `make lint` — no offenses. - `make test` — 2272 examples, 0 failures (96.06% line / 82.48% branch coverage). <!-- reporting-app - begin PR environment info --> ## Preview environment for reporting-app - Service endpoint: https://p-678-reporting-app-dev-669935554.us-east-1.elb.amazonaws.com - Deployed commit: 57100f5 <!-- reporting-app - end PR environment info --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 549b08e commit 9c84712

9 files changed

Lines changed: 326 additions & 1 deletion

reporting-app/app/business_processes/certification_business_process.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class CertificationBusinessProcess < Strata::BusinessProcess
99
REPORT_ACTIVITIES_STEP = "report_activities"
1010
REVIEW_ACTIVITY_REPORT_STEP = "review_activity_report"
1111
REVIEW_EXEMPTION_CLAIM_STEP = "review_exemption_claim"
12+
REVIEW_DENIAL_RESPONSE_STEP = "review_denial_response"
1213

1314
END_STEP = "end"
1415

@@ -27,6 +28,7 @@ class CertificationBusinessProcess < Strata::BusinessProcess
2728
applicant_task(REPORT_ACTIVITIES_STEP)
2829
staff_task(REVIEW_ACTIVITY_REPORT_STEP, ReviewActivityReportTask)
2930
staff_task(REVIEW_EXEMPTION_CLAIM_STEP, ReviewExemptionClaimTask)
31+
staff_task(REVIEW_DENIAL_RESPONSE_STEP, ReviewDenialResponseTask)
3032

3133
# --- Start ---
3234
start(EXTERNAL_EXEMPTION_CHECK_STEP, on: "CertificationCreated") do |event|
@@ -58,4 +60,7 @@ class CertificationBusinessProcess < Strata::BusinessProcess
5860
transition(REPORT_ACTIVITIES_STEP, "ExemptionApplicationFormSubmitted", REVIEW_EXEMPTION_CLAIM_STEP)
5961
transition(REVIEW_EXEMPTION_CLAIM_STEP, "DeterminedExempt", END_STEP)
6062
transition(REVIEW_EXEMPTION_CLAIM_STEP, "DeterminedNotExempt", REPORT_ACTIVITIES_STEP)
63+
64+
# --- Transitions: Denial response workflow ---
65+
transition(REPORT_ACTIVITIES_STEP, "DenialResponseApplicationFormSubmitted", REVIEW_DENIAL_RESPONSE_STEP)
6166
end
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
# A denial response is a lightweight way for a member to resolve a denied certification case while
4+
# their verification window is still open: a short written comment plus optional supporting
5+
# documents that a staff reviewer approves or denies.
6+
class DenialResponseApplicationForm < Strata::ApplicationForm
7+
include FormApprovalStatus
8+
has_review_task "ReviewDenialResponseTask"
9+
10+
strata_attribute :comment, :text
11+
12+
has_many_attached :supporting_documents
13+
14+
default_scope { with_attached_supporting_documents.includes(:determinations) }
15+
16+
validates :certification_case_id, presence: true
17+
validate :case_not_closed, on: :create
18+
validate :no_pending_forms, on: :create
19+
20+
# Include the case id so the submitted event routes to the case in the business process.
21+
def event_payload
22+
super.merge(case_id: certification_case_id)
23+
end
24+
25+
def self.has_pending_form(certification_case_id)
26+
DenialResponseApplicationForm.where(certification_case_id:, status: :in_progress).exists? ||
27+
ReviewDenialResponseTask.where(application_form: DenialResponseApplicationForm.where(certification_case_id:).all,
28+
status: [ :on_hold, :pending ]).exists?
29+
end
30+
31+
private
32+
33+
def case_not_closed
34+
certification_case = CertificationCase.find_by(id: certification_case_id)
35+
if certification_case.blank?
36+
errors.add(:certification_case_id, "is invalid")
37+
elsif certification_case.closed?
38+
errors.add(:certification_case_id, "has closed")
39+
elsif certification_case.verification_window_ended?
40+
errors.add(:certification_case_id, "verification window has ended")
41+
end
42+
end
43+
44+
def no_pending_forms
45+
errors.add(:certification_case_id, "has already been taken") if DenialResponseApplicationForm.has_pending_form(certification_case_id)
46+
end
47+
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
class ReviewDenialResponseTask < OscerTask
4+
before_validation :ensure_application_form
5+
6+
belongs_to :application_form, class_name: DenialResponseApplicationForm.name, inverse_of: :review_task, strict_loading: false
7+
8+
# Records the staff review decision. Nil until decided, distinguishable from approved/denied.
9+
enum :approval_status, { approved: "approved", denied: "denied" }
10+
11+
def self.application_form_class
12+
DenialResponseApplicationForm
13+
end
14+
end

reporting-app/app/services/member_status_service.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@ def status_from_case_step(certification_case)
175175
case certification_case.business_process_current_step
176176
when CertificationBusinessProcess::REPORT_ACTIVITIES_STEP
177177
awaiting_report_status
178-
when CertificationBusinessProcess::REVIEW_ACTIVITY_REPORT_STEP, CertificationBusinessProcess::REVIEW_EXEMPTION_CLAIM_STEP
178+
when CertificationBusinessProcess::REVIEW_ACTIVITY_REPORT_STEP, CertificationBusinessProcess::REVIEW_EXEMPTION_CLAIM_STEP,
179+
CertificationBusinessProcess::REVIEW_DENIAL_RESPONSE_STEP
179180
pending_review_status
180181
when CertificationBusinessProcess::END_STEP
181182
not_compliant_status

reporting-app/spec/business_processes/certification_business_process_spec.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,27 @@
240240
end
241241
end
242242

243+
describe 'denial response workflow' do
244+
it 'moves the case into review and creates the review task on submit' do
245+
# Step 1: Case starts on report_activities
246+
expect(certification_case.business_process_instance.current_step).to eq(CertificationBusinessProcess::REPORT_ACTIVITIES_STEP)
247+
expect(certification_case.member_status).to eq(MemberStatus::AWAITING_REPORT)
248+
expect(certification_case).to be_open
249+
250+
# Step 2: Member submits a denial response
251+
denial_response = create(:denial_response_application_form,
252+
certification_case_id: certification_case.id
253+
)
254+
denial_response.submit_application
255+
certification_case.reload
256+
257+
expect(certification_case.business_process_instance.current_step).to eq(CertificationBusinessProcess::REVIEW_DENIAL_RESPONSE_STEP)
258+
expect(certification_case.member_status).to eq(MemberStatus::PENDING_REVIEW)
259+
expect(certification_case).to be_open
260+
expect(ReviewDenialResponseTask.find_by(application_form: denial_response)).to be_present
261+
end
262+
end
263+
243264
describe 'business process events' do
244265
it 'publishes correct events throughout the workflow' do
245266
# Create and submit activity report
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
FactoryBot.define do
4+
factory :denial_response_application_form do
5+
id { SecureRandom.uuid }
6+
comment { "Here is my explanation for why my case should be reconsidered." }
7+
certification_case_id { create(:certification_case, certification: create(:certification)).id }
8+
9+
trait :with_submitted_status do
10+
after(:create) do |denial_response_application_form|
11+
denial_response_application_form.submit_application
12+
end
13+
end
14+
end
15+
end

reporting-app/spec/factories/strata_task_factory.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,13 @@
2828
type { "ReviewActivityReportTask" }
2929
association :application_form, factory: :activity_report_application_form
3030
end
31+
32+
factory :review_denial_response_task, parent: :oscer_task, class: ReviewDenialResponseTask do
33+
type { "ReviewDenialResponseTask" }
34+
end
35+
36+
factory :review_denial_response_task_with_form, parent: :oscer_task, class: ReviewDenialResponseTask do
37+
type { "ReviewDenialResponseTask" }
38+
association :application_form, factory: :denial_response_application_form
39+
end
3140
end
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe DenialResponseApplicationForm, type: :model do
6+
describe "attributes" do
7+
it "stores the member comment" do
8+
form = build(:denial_response_application_form, comment: "I have a good reason.")
9+
expect(form.comment).to eq("I have a good reason.")
10+
end
11+
12+
it "accepts optional supporting documents" do
13+
form = create(:denial_response_application_form)
14+
form.supporting_documents.attach(
15+
io: StringIO.new("doc"), filename: "evidence.pdf", content_type: "application/pdf"
16+
)
17+
18+
expect(form.supporting_documents).to be_attached
19+
end
20+
end
21+
22+
describe "lifecycle" do
23+
before { allow(Strata::EventManager).to receive(:publish) }
24+
25+
it "starts in progress" do
26+
form = create(:denial_response_application_form)
27+
expect(form).to be_in_progress
28+
end
29+
30+
it "transitions to submitted and records submitted_at on submit" do
31+
form = create(:denial_response_application_form)
32+
33+
expect(form.submit_application).to be(true)
34+
expect(form).to be_submitted
35+
expect(form.submitted_at).to be_present
36+
end
37+
38+
it "publishes a submitted event carrying the case id" do
39+
form = create(:denial_response_application_form)
40+
form.submit_application
41+
42+
expect(Strata::EventManager).to have_received(:publish).with(
43+
"DenialResponseApplicationFormSubmitted",
44+
hash_including(application_form_id: form.id, case_id: form.certification_case_id)
45+
)
46+
end
47+
end
48+
49+
describe "validations" do
50+
let(:certification) { create(:certification) }
51+
let(:certification_case) { create(:certification_case, certification: certification) }
52+
53+
it "requires a certification case" do
54+
form = build(:denial_response_application_form, certification_case_id: nil)
55+
expect(form.save).to be(false)
56+
expect(form.errors[:certification_case_id]).to include("can't be blank")
57+
end
58+
59+
it "allows only one in-progress form per case" do
60+
create(:denial_response_application_form, certification_case_id: certification_case.id)
61+
second_form = build(:denial_response_application_form, certification_case_id: certification_case.id)
62+
63+
expect(second_form.save).to be(false)
64+
expect(second_form.errors[:certification_case_id]).to include("has already been taken")
65+
end
66+
67+
it "allows different certification_case_ids" do
68+
certification_case_2 = create(:certification_case)
69+
create(:denial_response_application_form, certification_case_id: certification_case.id)
70+
second_form = build(:denial_response_application_form, certification_case_id: certification_case_2.id)
71+
72+
expect(second_form.save).to be(true)
73+
end
74+
75+
# Submitting moves the case to review and the business process creates the review task, so the
76+
# prior form here is submitted (not in progress) — isolating the review-task branch of
77+
# has_pending_form.
78+
context "with a prior submitted form whose review task is still open" do
79+
let!(:first_form) { create(:denial_response_application_form, :with_submitted_status, certification_case_id: certification_case.id) }
80+
let(:review_task) { ReviewDenialResponseTask.find_by(application_form: first_form) }
81+
82+
it "does not allow a new form while the review task is pending" do
83+
second_form = build(:denial_response_application_form, certification_case_id: certification_case.id)
84+
85+
expect(second_form.save).to be(false)
86+
expect(second_form.errors[:certification_case_id]).to include("has already been taken")
87+
end
88+
89+
it "does not allow a new form while the review task is on hold" do
90+
review_task.on_hold!
91+
second_form = build(:denial_response_application_form, certification_case_id: certification_case.id)
92+
93+
expect(second_form.save).to be(false)
94+
end
95+
96+
it "allows a new form once the review task is completed" do
97+
review_task.completed!
98+
second_form = build(:denial_response_application_form, certification_case_id: certification_case.id)
99+
100+
expect(second_form.save).to be(true)
101+
end
102+
end
103+
104+
context "when case is closed" do
105+
before { certification_case.close! }
106+
107+
it "does not allow creation" do
108+
form = build(:denial_response_application_form, certification_case_id: certification_case.id)
109+
expect(form.save).to be(false)
110+
expect(form.errors[:certification_case_id]).to include("has closed")
111+
end
112+
end
113+
114+
context "when verification window has ended" do
115+
before { certification_case.update_attribute(:verification_window_end_date, 1.day.ago) }
116+
117+
it "does not allow creation" do
118+
form = build(:denial_response_application_form, certification_case_id: certification_case.id)
119+
expect(form.save).to be(false)
120+
expect(form.errors[:certification_case_id]).to include("verification window has ended")
121+
end
122+
end
123+
124+
context "when verification window is open" do
125+
before { certification_case.update_attribute(:verification_window_end_date, 1.day.from_now) }
126+
127+
it "allows creation" do
128+
form = build(:denial_response_application_form, certification_case_id: certification_case.id)
129+
expect(form.save).to be(true)
130+
end
131+
end
132+
end
133+
134+
describe "#approval_status" do
135+
it "has no outcome when there is no review task, distinguishable from approved/denied" do
136+
form = create(:denial_response_application_form)
137+
138+
expect(form.approval_status).to be_nil
139+
expect(form).not_to be_approved
140+
expect(form).not_to be_denied
141+
end
142+
143+
it "reports its review task's approved outcome" do
144+
task = create(:review_denial_response_task_with_form, case: create(:certification_case))
145+
task.update!(approval_status: :approved)
146+
147+
form = described_class.find(task.application_form_id)
148+
expect(form.approval_status).to eq("approved")
149+
expect(form).to be_approved
150+
end
151+
152+
it "reports its review task's denied outcome" do
153+
task = create(:review_denial_response_task_with_form, case: create(:certification_case))
154+
task.update!(approval_status: :denied)
155+
156+
form = described_class.find(task.application_form_id)
157+
expect(form.approval_status).to eq("denied")
158+
expect(form).to be_denied
159+
end
160+
end
161+
end
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe ReviewDenialResponseTask, type: :model do
6+
describe "inheritance" do
7+
it "inherits from Strata::Task" do
8+
expect(described_class < Strata::Task).to be true
9+
end
10+
11+
it "has a case_type of CertificationCase" do
12+
task = create(:review_denial_response_task_with_form, case: create(:certification_case))
13+
expect(task.case_type).to eq("CertificationCase")
14+
end
15+
end
16+
17+
describe "create" do
18+
let(:certification_case) { create(:certification_case) }
19+
let!(:denial_response_application_form) { create(:denial_response_application_form, certification_case_id: certification_case.id) }
20+
21+
it "binds to the application form" do
22+
task = described_class.create!(case: certification_case)
23+
expect(task.application_form_id).to eq(denial_response_application_form.id)
24+
end
25+
end
26+
27+
describe "#approval_status" do
28+
let(:certification_case) { create(:certification_case) }
29+
30+
it "is nil (undecided) by default, distinguishable from approved/denied" do
31+
task = create(:review_denial_response_task_with_form, case: certification_case)
32+
33+
expect(task.approval_status).to be_nil
34+
expect(task).not_to be_approved
35+
expect(task).not_to be_denied
36+
end
37+
38+
it "records an approved decision" do
39+
task = create(:review_denial_response_task_with_form, case: certification_case, approval_status: :approved)
40+
41+
expect(task.approval_status).to eq("approved")
42+
expect(task).to be_approved
43+
end
44+
45+
it "records a denied decision" do
46+
task = create(:review_denial_response_task_with_form, case: certification_case, approval_status: :denied)
47+
48+
expect(task.approval_status).to eq("denied")
49+
expect(task).to be_denied
50+
end
51+
end
52+
end

0 commit comments

Comments
 (0)