Add consultation-nested assessment create and show routes.
Deploy Ladill Care / deploy (push) Successful in 32s

Index alone still left /consultations/{uuid}/assessments/create and
/consultations/{uuid}/assessments/{uuid} unmatched (404). Wire those
paths, redirect store into the nested show, and cover the doctor form
open flow with feature tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 20:47:57 +00:00
co-authored by Cursor
parent 1128ea31cc
commit 3854fc6471
8 changed files with 194 additions and 25 deletions
@@ -75,6 +75,42 @@ class AssessmentController extends Controller
return $this->index($request, $consultation->patient);
}
/**
* Start-form picker under a consultation (parity with prescriptions create).
* GET /consultations/{uuid}/assessments/create
*/
public function createForConsultation(Request $request, Consultation $consultation): View
{
$this->authorizeConsultation($request, $consultation);
$consultation->loadMissing(['patient', 'visit']);
$return = app(ConsultationReturnContext::class);
$return->remember($consultation);
$request->query->set('consultation_uuid', $consultation->uuid);
if ($consultation->visit?->uuid) {
$request->query->set('visit_uuid', $consultation->visit->uuid);
}
$request->query->set(ConsultationReturnContext::QUERY_KEY, $consultation->uuid);
return $this->create($request, $consultation->patient);
}
/**
* Open an assessment while staying on the consultation URL tree.
* GET /consultations/{uuid}/assessments/{assessment}
*/
public function showForConsultation(Request $request, Consultation $consultation, Assessment $assessment): View
{
$this->authorizeConsultation($request, $consultation);
abort_unless((int) $assessment->patient_id === (int) $consultation->patient_id, 404);
$return = app(ConsultationReturnContext::class);
$return->remember($consultation);
$request->query->set(ConsultationReturnContext::QUERY_KEY, $consultation->uuid);
return $this->show($request, $assessment);
}
public function create(Request $request, Patient $patient): View
{
$this->assertEngineEnabled($request);
@@ -99,15 +135,19 @@ class AssessmentController extends Controller
}
})->values();
$returnConsultation = app(ConsultationReturnContext::class)
->resolve($request, (int) $patient->id);
$returnConsultation?->loadMissing('visit');
return view('care.assessments.create', [
'patient' => $patient,
'templates' => $capturable,
'categories' => config('care.assessment_template_categories'),
'consultationUuid' => $request->query('consultation_uuid'),
'visitUuid' => $request->query('visit_uuid'),
'consultationUuid' => $request->query('consultation_uuid') ?? $returnConsultation?->uuid,
'visitUuid' => $request->query('visit_uuid') ?? $returnConsultation?->visit?->uuid,
'returnConsultation' => $returnConsultation,
]);
}
public function store(Request $request, Patient $patient): RedirectResponse
{
$this->assertEngineEnabled($request);
@@ -130,6 +170,16 @@ class AssessmentController extends Controller
);
$return = app(ConsultationReturnContext::class);
if (! empty($context['consultation'])) {
$return->remember($context['consultation']);
return redirect()
->route('care.consultations.assessments.show', [
'consultation' => $context['consultation'],
'assessment' => $assessment,
])
->with('success', 'Assessment started.');
}
return redirect()
->route('care.assessments.show', array_filter([
@@ -168,10 +218,10 @@ class AssessmentController extends Controller
$return->remember($consultation);
return redirect()
->route('care.assessments.show', array_filter([
->route('care.consultations.assessments.show', [
'consultation' => $consultation,
'assessment' => $assessment,
...$return->query($consultation),
]))
])
->with('success', 'Assessment started.');
}
@@ -240,11 +290,7 @@ class AssessmentController extends Controller
$return = app(ConsultationReturnContext::class);
return redirect()
->route('care.assessments.show', array_filter([
'assessment' => $assessment,
...$return->routeQuery($request),
]))
return $this->redirectToAssessmentShow($request, $assessment, $return)
->with('success', 'Assessment saved.');
}
@@ -280,11 +326,7 @@ class AssessmentController extends Controller
$return = app(ConsultationReturnContext::class);
return redirect()
->route('care.assessments.show', array_filter([
'assessment' => $assessment,
...$return->routeQuery($request),
]))
return $this->redirectToAssessmentShow($request, $assessment, $return)
->with('success', 'Assessment completed.');
}
@@ -301,11 +343,41 @@ class AssessmentController extends Controller
$this->ownerRef($request),
);
$return = app(ConsultationReturnContext::class);
$consultation = $return->resolve($request, (int) $assessment->patient_id);
if ($consultation) {
return redirect()
->route('care.consultations.assessments.index', $consultation)
->with('success', 'Assessment cancelled.');
}
return redirect()
->route('care.assessments.index', $assessment->patient)
->with('success', 'Assessment cancelled.');
}
/**
* Prefer consultation-nested show when the doctor is mid-consult.
*/
protected function redirectToAssessmentShow(
Request $request,
Assessment $assessment,
ConsultationReturnContext $return,
): RedirectResponse {
$consultation = $return->resolve($request, (int) $assessment->patient_id)
?? $return->resolveLinked($request, $assessment->consultation);
if ($consultation) {
return redirect()->route('care.consultations.assessments.show', [
'consultation' => $consultation,
'assessment' => $assessment,
]);
}
return redirect()->route('care.assessments.show', $assessment);
}
/**
* FHIR R4 Bundle (Questionnaire + QuestionnaireResponse) download.
*/
@@ -1,4 +1,6 @@
<x-app-layout :title="'Start assessment · '.$patient->fullName()">
<x-care.consultation-return :consultation="$returnConsultation ?? null" />
<div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Start assessment</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ $patient->fullName() }}</h1>
@@ -11,7 +13,9 @@
@if ($templates->isEmpty())
<p class="text-sm text-slate-500">No forms are available for your role right now. Ask an administrator to confirm clinical forms are enabled in Settings.</p>
@else
<form method="POST" action="{{ route('care.assessments.store', $patient) }}" class="space-y-4">
<form method="POST" action="{{ ($returnConsultation ?? null)
? route('care.consultations.assessments.store', $returnConsultation)
: route('care.assessments.store', $patient) }}" class="space-y-4">
@csrf
@if ($consultationUuid)
<input type="hidden" name="consultation_uuid" value="{{ $consultationUuid }}">
@@ -19,6 +23,9 @@
@if ($visitUuid)
<input type="hidden" name="visit_uuid" value="{{ $visitUuid }}">
@endif
@if (($returnConsultation ?? null)?->uuid)
<input type="hidden" name="from_consultation" value="{{ $returnConsultation->uuid }}">
@endif
<div>
<label class="block text-sm font-medium text-slate-700">Template</label>
@@ -38,7 +45,9 @@
<div class="flex gap-3">
<button type="submit" class="btn-primary">Start</button>
<a href="{{ route('care.assessments.index', $patient) }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">Cancel</a>
<a href="{{ ($returnConsultation ?? null)
? route('care.consultations.assessments.index', $returnConsultation)
: route('care.assessments.index', $patient) }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">Cancel</a>
</div>
</form>
@endif
@@ -10,7 +10,11 @@
</p>
</div>
@if ($canCapture)
<a href="{{ route('care.assessments.create', $patient) }}" class="btn-primary">Start assessment</a>
@if ($returnConsultation ?? null)
<a href="{{ route('care.consultations.assessments.create', $returnConsultation) }}" class="btn-primary">Start assessment</a>
@else
<a href="{{ route('care.assessments.create', $patient) }}" class="btn-primary">Start assessment</a>
@endif
@endif
</div>
@@ -53,7 +57,11 @@
{{ $assessment->assessed_at?->format('d M Y H:i') ?? $assessment->created_at?->format('d M Y H:i') ?? '—' }}
</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('care.assessments.show', $assessment) }}" class="text-sky-600 hover:text-sky-700">Open</a>
@if ($returnConsultation ?? null)
<a href="{{ route('care.consultations.assessments.show', [$returnConsultation, $assessment]) }}" class="text-sky-600 hover:text-sky-700">Open</a>
@else
<a href="{{ route('care.assessments.show', $assessment) }}" class="text-sky-600 hover:text-sky-700">Open</a>
@endif
</td>
</tr>
@empty
@@ -17,7 +17,9 @@
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ route('care.assessments.fhir', $assessment) }}?download=1" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Export FHIR</a>
<a href="{{ route('care.assessments.index', $assessment->patient) }}" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">All assessments</a>
<a href="{{ ($returnConsultation ?? null)
? route('care.consultations.assessments.index', $returnConsultation)
: route('care.assessments.index', $assessment->patient) }}" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">All assessments</a>
</div>
</div>
@@ -57,7 +57,7 @@
</p>
</div>
@if ($universalAssessment)
<a href="{{ route('care.assessments.show', [$universalAssessment, 'from_consultation' => $consultation->uuid]) }}" class="rounded-lg bg-sky-600 px-3 py-2 text-sm font-medium text-white hover:bg-sky-700">
<a href="{{ route('care.consultations.assessments.show', [$consultation, $universalAssessment]) }}" class="rounded-lg bg-sky-600 px-3 py-2 text-sm font-medium text-white hover:bg-sky-700">
{{ $universalAssessment->isDraft() ? 'Continue form' : 'View form' }}
</a>
@elseif (($canCaptureUniversal ?? false) && ! $isCompleted)
@@ -145,7 +145,7 @@
<span class="text-xs text-amber-600">Form not available yet</span>
@elseif ($instrument['assessment'])
<span class="text-xs text-slate-500">{{ $assessmentStatuses[$instrument['assessment']->status] ?? $instrument['assessment']->status }}</span>
<a href="{{ route('care.assessments.show', [$instrument['assessment'], 'from_consultation' => $consultation->uuid]) }}" class="text-sky-600 hover:text-sky-700">
<a href="{{ route('care.consultations.assessments.show', [$consultation, $instrument['assessment']]) }}" class="text-sky-600 hover:text-sky-700">
{{ $instrument['assessment']->isDraft() ? 'Continue' : 'View' }}
</a>
@elseif (($canCaptureAssessment ?? false) && ! $isCompleted)
@@ -209,7 +209,7 @@
<ul class="mt-4 space-y-2 text-sm">
@foreach ($consultationAssessments as $item)
<li>
<a href="{{ route('care.assessments.show', [$item, 'from_consultation' => $consultation->uuid]) }}" class="text-sky-600 hover:text-sky-700">
<a href="{{ route('care.consultations.assessments.show', [$consultation, $item]) }}" class="text-sky-600 hover:text-sky-700">
{{ $item->template->name }}
</a>
· {{ ($assessmentStatuses[$item->status] ?? $item->status) }}
@@ -390,7 +390,7 @@
<section class="rounded-2xl border border-sky-100 bg-white p-6">
<div class="flex items-center justify-between gap-2">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Patient history form</h2>
<a href="{{ route('care.assessments.show', [$universalAssessment, 'from_consultation' => $consultation->uuid]) }}" class="text-sm text-sky-600 hover:text-sky-700">Open</a>
<a href="{{ route('care.consultations.assessments.show', [$consultation, $universalAssessment]) }}" class="text-sm text-sky-600 hover:text-sky-700">Open</a>
</div>
<dl class="mt-4 space-y-2 text-sm">
@foreach ($universalAssessment->answers->sortBy(fn ($a) => $a->question?->sort_order ?? 0) as $answer)
@@ -43,6 +43,7 @@
'name' => 'Clinical forms',
'route' => route('care.patients.index', ['focus' => 'assessments']),
'active' => request()->routeIs('care.assessments.*')
|| request()->routeIs('care.consultations.assessments.*')
|| request()->routeIs('care.pathways.*')
|| request()->routeIs('care.outcomes.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" />',
+3
View File
@@ -219,8 +219,11 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/patients/{patient}/assessments', [AssessmentController::class, 'index'])->name('care.assessments.index');
Route::get('/patients/{patient}/assessments/create', [AssessmentController::class, 'create'])->name('care.assessments.create');
Route::post('/patients/{patient}/assessments', [AssessmentController::class, 'store'])->name('care.assessments.store');
// Consultation-scoped: index / create / show (create before {assessment} so "create" is not bound as uuid)
Route::get('/consultations/{consultation}/assessments', [AssessmentController::class, 'indexForConsultation'])->name('care.consultations.assessments.index');
Route::get('/consultations/{consultation}/assessments/create', [AssessmentController::class, 'createForConsultation'])->name('care.consultations.assessments.create');
Route::post('/consultations/{consultation}/assessments', [AssessmentController::class, 'storeForConsultation'])->name('care.consultations.assessments.store');
Route::get('/consultations/{consultation}/assessments/{assessment}', [AssessmentController::class, 'showForConsultation'])->name('care.consultations.assessments.show');
Route::get('/assessments/{assessment}', [AssessmentController::class, 'show'])->name('care.assessments.show');
Route::put('/assessments/{assessment}', [AssessmentController::class, 'update'])->name('care.assessments.update');
Route::post('/assessments/{assessment}/complete', [AssessmentController::class, 'complete'])->name('care.assessments.complete');
@@ -400,6 +400,76 @@ class CareAssessmentCaptureTest extends TestCase
$this->assertSame($this->branch->id, $assessment->branch_id);
}
public function test_consultation_create_show_and_store_open_forms_for_doctor(): void
{
$this->setMemberRole('doctor');
$visit = Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'status' => Visit::STATUS_OPEN,
'checked_in_at' => now(),
]);
$consultation = Consultation::create([
'owner_ref' => $this->user->public_id,
'visit_id' => $visit->id,
'patient_id' => $this->patient->id,
'status' => Consultation::STATUS_DRAFT,
'started_at' => now(),
]);
// Nested create (was unmatched → Laravel 404 before this fix)
$this->actingAs($this->user)
->get(route('care.consultations.assessments.create', $consultation))
->assertOk()
->assertSee('Start assessment')
->assertSee('Universal Intake');
$this->actingAs($this->user)
->get('/consultations/'.$consultation->uuid.'/assessments/create')
->assertOk();
$response = $this->actingAs($this->user)
->post(route('care.consultations.assessments.store', $consultation), [
'template_code' => 'universal_intake',
]);
$assessment = Assessment::first();
$this->assertNotNull($assessment);
$response->assertRedirect(route('care.consultations.assessments.show', [
'consultation' => $consultation,
'assessment' => $assessment,
]));
// Nested show (was unmatched → Laravel 404)
$this->actingAs($this->user)
->get(route('care.consultations.assessments.show', [$consultation, $assessment]))
->assertOk()
->assertSee('Universal Intake')
->assertSee('Back to consultation');
$this->actingAs($this->user)
->get('/consultations/'.$consultation->uuid.'/assessments/'.$assessment->uuid)
->assertOk();
// Flat show still works for patient-chart / bookmarks
$this->actingAs($this->user)
->get(route('care.assessments.show', $assessment))
->assertOk();
// Patient create + store with consultation_uuid also lands on nested show
$this->actingAs($this->user)
->get(route('care.assessments.create', [
'patient' => $this->patient,
'consultation_uuid' => $consultation->uuid,
]))
->assertOk();
}
public function test_public_bodies_use_consultation_uuid(): void
{
$this->setMemberRole('doctor');
@@ -430,6 +500,10 @@ class CareAssessmentCaptureTest extends TestCase
$assessment = Assessment::first();
$this->assertSame($consultation->id, $assessment->consultation_id);
$this->actingAs($this->user)
->get(route('care.consultations.assessments.show', [$consultation, $assessment]))
->assertOk();
}
public function test_tenant_isolation_returns_404(): void