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); 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 public function create(Request $request, Patient $patient): View
{ {
$this->assertEngineEnabled($request); $this->assertEngineEnabled($request);
@@ -99,15 +135,19 @@ class AssessmentController extends Controller
} }
})->values(); })->values();
$returnConsultation = app(ConsultationReturnContext::class)
->resolve($request, (int) $patient->id);
$returnConsultation?->loadMissing('visit');
return view('care.assessments.create', [ return view('care.assessments.create', [
'patient' => $patient, 'patient' => $patient,
'templates' => $capturable, 'templates' => $capturable,
'categories' => config('care.assessment_template_categories'), 'categories' => config('care.assessment_template_categories'),
'consultationUuid' => $request->query('consultation_uuid'), 'consultationUuid' => $request->query('consultation_uuid') ?? $returnConsultation?->uuid,
'visitUuid' => $request->query('visit_uuid'), 'visitUuid' => $request->query('visit_uuid') ?? $returnConsultation?->visit?->uuid,
'returnConsultation' => $returnConsultation,
]); ]);
} }
public function store(Request $request, Patient $patient): RedirectResponse public function store(Request $request, Patient $patient): RedirectResponse
{ {
$this->assertEngineEnabled($request); $this->assertEngineEnabled($request);
@@ -130,6 +170,16 @@ class AssessmentController extends Controller
); );
$return = app(ConsultationReturnContext::class); $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() return redirect()
->route('care.assessments.show', array_filter([ ->route('care.assessments.show', array_filter([
@@ -168,10 +218,10 @@ class AssessmentController extends Controller
$return->remember($consultation); $return->remember($consultation);
return redirect() return redirect()
->route('care.assessments.show', array_filter([ ->route('care.consultations.assessments.show', [
'consultation' => $consultation,
'assessment' => $assessment, 'assessment' => $assessment,
...$return->query($consultation), ])
]))
->with('success', 'Assessment started.'); ->with('success', 'Assessment started.');
} }
@@ -240,11 +290,7 @@ class AssessmentController extends Controller
$return = app(ConsultationReturnContext::class); $return = app(ConsultationReturnContext::class);
return redirect() return $this->redirectToAssessmentShow($request, $assessment, $return)
->route('care.assessments.show', array_filter([
'assessment' => $assessment,
...$return->routeQuery($request),
]))
->with('success', 'Assessment saved.'); ->with('success', 'Assessment saved.');
} }
@@ -280,11 +326,7 @@ class AssessmentController extends Controller
$return = app(ConsultationReturnContext::class); $return = app(ConsultationReturnContext::class);
return redirect() return $this->redirectToAssessmentShow($request, $assessment, $return)
->route('care.assessments.show', array_filter([
'assessment' => $assessment,
...$return->routeQuery($request),
]))
->with('success', 'Assessment completed.'); ->with('success', 'Assessment completed.');
} }
@@ -301,11 +343,41 @@ class AssessmentController extends Controller
$this->ownerRef($request), $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() return redirect()
->route('care.assessments.index', $assessment->patient) ->route('care.assessments.index', $assessment->patient)
->with('success', 'Assessment cancelled.'); ->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. * FHIR R4 Bundle (Questionnaire + QuestionnaireResponse) download.
*/ */
@@ -1,4 +1,6 @@
<x-app-layout :title="'Start assessment · '.$patient->fullName()"> <x-app-layout :title="'Start assessment · '.$patient->fullName()">
<x-care.consultation-return :consultation="$returnConsultation ?? null" />
<div> <div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Start assessment</p> <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> <h1 class="text-2xl font-semibold text-slate-900">{{ $patient->fullName() }}</h1>
@@ -11,7 +13,9 @@
@if ($templates->isEmpty()) @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> <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 @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 @csrf
@if ($consultationUuid) @if ($consultationUuid)
<input type="hidden" name="consultation_uuid" value="{{ $consultationUuid }}"> <input type="hidden" name="consultation_uuid" value="{{ $consultationUuid }}">
@@ -19,6 +23,9 @@
@if ($visitUuid) @if ($visitUuid)
<input type="hidden" name="visit_uuid" value="{{ $visitUuid }}"> <input type="hidden" name="visit_uuid" value="{{ $visitUuid }}">
@endif @endif
@if (($returnConsultation ?? null)?->uuid)
<input type="hidden" name="from_consultation" value="{{ $returnConsultation->uuid }}">
@endif
<div> <div>
<label class="block text-sm font-medium text-slate-700">Template</label> <label class="block text-sm font-medium text-slate-700">Template</label>
@@ -38,7 +45,9 @@
<div class="flex gap-3"> <div class="flex gap-3">
<button type="submit" class="btn-primary">Start</button> <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> </div>
</form> </form>
@endif @endif
@@ -10,7 +10,11 @@
</p> </p>
</div> </div>
@if ($canCapture) @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 @endif
</div> </div>
@@ -53,7 +57,11 @@
{{ $assessment->assessed_at?->format('d M Y H:i') ?? $assessment->created_at?->format('d M Y H:i') ?? '—' }} {{ $assessment->assessed_at?->format('d M Y H:i') ?? $assessment->created_at?->format('d M Y H:i') ?? '—' }}
</td> </td>
<td class="px-4 py-3 text-right"> <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> </td>
</tr> </tr>
@empty @empty
@@ -17,7 +17,9 @@
</div> </div>
<div class="flex flex-wrap gap-2"> <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.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>
</div> </div>
@@ -57,7 +57,7 @@
</p> </p>
</div> </div>
@if ($universalAssessment) @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' }} {{ $universalAssessment->isDraft() ? 'Continue form' : 'View form' }}
</a> </a>
@elseif (($canCaptureUniversal ?? false) && ! $isCompleted) @elseif (($canCaptureUniversal ?? false) && ! $isCompleted)
@@ -145,7 +145,7 @@
<span class="text-xs text-amber-600">Form not available yet</span> <span class="text-xs text-amber-600">Form not available yet</span>
@elseif ($instrument['assessment']) @elseif ($instrument['assessment'])
<span class="text-xs text-slate-500">{{ $assessmentStatuses[$instrument['assessment']->status] ?? $instrument['assessment']->status }}</span> <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' }} {{ $instrument['assessment']->isDraft() ? 'Continue' : 'View' }}
</a> </a>
@elseif (($canCaptureAssessment ?? false) && ! $isCompleted) @elseif (($canCaptureAssessment ?? false) && ! $isCompleted)
@@ -209,7 +209,7 @@
<ul class="mt-4 space-y-2 text-sm"> <ul class="mt-4 space-y-2 text-sm">
@foreach ($consultationAssessments as $item) @foreach ($consultationAssessments as $item)
<li> <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 }} {{ $item->template->name }}
</a> </a>
· {{ ($assessmentStatuses[$item->status] ?? $item->status) }} · {{ ($assessmentStatuses[$item->status] ?? $item->status) }}
@@ -390,7 +390,7 @@
<section class="rounded-2xl border border-sky-100 bg-white p-6"> <section class="rounded-2xl border border-sky-100 bg-white p-6">
<div class="flex items-center justify-between gap-2"> <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> <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> </div>
<dl class="mt-4 space-y-2 text-sm"> <dl class="mt-4 space-y-2 text-sm">
@foreach ($universalAssessment->answers->sortBy(fn ($a) => $a->question?->sort_order ?? 0) as $answer) @foreach ($universalAssessment->answers->sortBy(fn ($a) => $a->question?->sort_order ?? 0) as $answer)
@@ -43,6 +43,7 @@
'name' => 'Clinical forms', 'name' => 'Clinical forms',
'route' => route('care.patients.index', ['focus' => 'assessments']), 'route' => route('care.patients.index', ['focus' => 'assessments']),
'active' => request()->routeIs('care.assessments.*') 'active' => request()->routeIs('care.assessments.*')
|| request()->routeIs('care.consultations.assessments.*')
|| request()->routeIs('care.pathways.*') || request()->routeIs('care.pathways.*')
|| request()->routeIs('care.outcomes.*'), || 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" />', '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', [AssessmentController::class, 'index'])->name('care.assessments.index');
Route::get('/patients/{patient}/assessments/create', [AssessmentController::class, 'create'])->name('care.assessments.create'); 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'); 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', [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::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::get('/assessments/{assessment}', [AssessmentController::class, 'show'])->name('care.assessments.show');
Route::put('/assessments/{assessment}', [AssessmentController::class, 'update'])->name('care.assessments.update'); Route::put('/assessments/{assessment}', [AssessmentController::class, 'update'])->name('care.assessments.update');
Route::post('/assessments/{assessment}/complete', [AssessmentController::class, 'complete'])->name('care.assessments.complete'); 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); $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 public function test_public_bodies_use_consultation_uuid(): void
{ {
$this->setMemberRole('doctor'); $this->setMemberRole('doctor');
@@ -430,6 +500,10 @@ class CareAssessmentCaptureTest extends TestCase
$assessment = Assessment::first(); $assessment = Assessment::first();
$this->assertSame($consultation->id, $assessment->consultation_id); $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 public function test_tenant_isolation_returns_404(): void