Files
ladill-care/app/Http/Controllers/Care/AssessmentController.php
T
isaaccladandCursor 847d63c1c2
Deploy Ladill Care / deploy (push) Successful in 40s
Allow consultation assessments when patient home branch differs.
Doctors scoped to the visit branch were getting app 404s on nested
assessment routes because authorizePatient compared the patient's home
branch, unlike consultation/prescription auth which uses the visit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 21:26:58 +00:00

508 lines
19 KiB
PHP

<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Http\Controllers\Controller;
use App\Models\Assessment;
use App\Models\AssessmentTemplate;
use App\Models\Consultation;
use App\Models\Patient;
use App\Models\Visit;
use App\Services\Care\AssessmentService;
use App\Services\Care\CareFeatures;
use App\Services\Care\CarePermissions;
use App\Services\Care\ConsultationReturnContext;
use App\Services\Care\FhirAssessmentExporter;
use App\Services\Care\OrganizationResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AssessmentController extends Controller
{
use ScopesToAccount;
public function __construct(
protected AssessmentService $assessments,
protected CareFeatures $features,
protected CarePermissions $permissions,
protected FhirAssessmentExporter $fhir,
) {}
public function index(Request $request, Patient $patient): View
{
$this->assertEngineEnabled($request);
$this->authorizeAbility($request, 'assessments.view');
$this->authorizePatient($request, $patient);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$assessments = $this->assessments->listForPatient(
$patient,
$this->ownerRef($request),
$request->only(['status', 'template_code', 'category', 'from', 'to', 'per_page']),
$branchScope,
);
$canCapture = $this->canCapture($request);
return view('care.assessments.index', [
'patient' => $patient,
'assessments' => $assessments,
'canCapture' => $canCapture,
'statuses' => config('care.assessment_statuses'),
'categories' => config('care.assessment_template_categories'),
'filters' => $request->only(['status', 'template_code', 'category']),
'returnConsultation' => app(ConsultationReturnContext::class)->resolve($request, (int) $patient->id),
]);
}
/**
* Consultation-scoped assessments list (same patient forms UI with return context).
* Supports GET /consultations/{uuid}/assessments — previously only POST existed.
*/
public function indexForConsultation(Request $request, Consultation $consultation): View
{
$this->authorizeConsultation($request, $consultation);
$consultation->loadMissing('patient');
$return = app(ConsultationReturnContext::class);
$return->remember($consultation);
$request->query->set(ConsultationReturnContext::QUERY_KEY, $consultation->uuid);
// Visit branch may differ from the patient's home branch; consultation
// auth already enforced visit-branch scope (same as prescriptions).
$request->attributes->set('care.assessments.skip_patient_branch', true);
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);
$request->attributes->set('care.assessments.skip_patient_branch', true);
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);
$this->authorizeCapture($request);
$this->authorizePatient($request, $patient);
$templates = AssessmentTemplate::query()
->system()
->current()
->orderBy('category')
->orderBy('name')
->get();
$member = $this->member($request);
$capturable = $templates->filter(function (AssessmentTemplate $template) use ($member) {
try {
$this->assessments->assertCaptureAllowed($member, $template);
return true;
} catch (\Throwable) {
return false;
}
})->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') ?? $returnConsultation?->uuid,
'visitUuid' => $request->query('visit_uuid') ?? $returnConsultation?->visit?->uuid,
'returnConsultation' => $returnConsultation,
]);
}
public function store(Request $request, Patient $patient): RedirectResponse
{
$this->assertEngineEnabled($request);
$this->authorizeCapture($request);
$this->authorizePatient($request, $patient);
$validated = $request->validate([
'template_code' => ['required', 'string', 'max:100'],
'consultation_uuid' => ['nullable', 'uuid'],
'visit_uuid' => ['nullable', 'uuid'],
]);
$context = $this->resolveContext($request, $patient, $validated);
$assessment = $this->assessments->start(
$patient,
$validated['template_code'],
$this->ownerRef($request),
$this->member($request),
$context,
);
$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([
'assessment' => $assessment,
...$return->routeQuery($request),
]))
->with('success', 'Assessment started.');
}
public function storeForConsultation(Request $request, Consultation $consultation): RedirectResponse
{
$this->assertEngineEnabled($request);
$this->authorizeCapture($request);
$this->authorizeConsultation($request, $consultation);
$validated = $request->validate([
'template_code' => ['required', 'string', 'max:100'],
]);
$consultation->loadMissing(['patient', 'visit']);
// Org/owner only — branch was checked via authorizeConsultation (visit).
$request->attributes->set('care.assessments.skip_patient_branch', true);
$this->authorizePatient($request, $consultation->patient);
$assessment = $this->assessments->start(
$consultation->patient,
$validated['template_code'],
$this->ownerRef($request),
$this->member($request),
[
'consultation' => $consultation,
'visit' => $consultation->visit,
'actor' => $this->ownerRef($request),
],
);
$return = app(ConsultationReturnContext::class);
$return->remember($consultation);
return redirect()
->route('care.consultations.assessments.show', [
'consultation' => $consultation,
'assessment' => $assessment,
])
->with('success', 'Assessment started.');
}
public function show(Request $request, Assessment $assessment): View
{
$this->assertEngineEnabled($request);
$this->authorizeAbility($request, 'assessments.view');
$this->authorizeAssessment($request, $assessment);
$assessment->load([
'template.questions',
'answers.question',
'patient',
'consultation',
'visit',
'practitioner',
'score',
]);
$answersByCode = $assessment->answers
->mapWithKeys(fn ($answer) => [
$answer->question->code => $answer->authoritativeValue($answer->question->answer_type),
]);
$canEdit = false;
if ($assessment->isDraft() && $this->canCapture($request)) {
try {
$this->assessments->assertCaptureAllowed($this->member($request), $assessment->template);
$canEdit = true;
} catch (\Throwable) {
$canEdit = false;
}
}
$return = app(ConsultationReturnContext::class);
return view('care.assessments.show', [
'assessment' => $assessment,
'answersByCode' => $answersByCode,
'canEdit' => $canEdit,
'statuses' => config('care.assessment_statuses'),
'returnConsultation' => $return->resolve($request, (int) $assessment->patient_id)
?? $return->resolveLinked($request, $assessment->consultation),
]);
}
public function update(Request $request, Assessment $assessment): RedirectResponse
{
$this->assertEngineEnabled($request);
$this->authorizeCapture($request);
$this->authorizeAssessment($request, $assessment);
$validated = $request->validate([
'answers' => ['nullable', 'array'],
'notes' => ['nullable', 'string', 'max:10000'],
]);
$this->assessments->saveAnswers(
$assessment,
$this->ownerRef($request),
$validated['answers'] ?? [],
$this->member($request),
$this->ownerRef($request),
array_key_exists('notes', $validated) ? $validated['notes'] : null,
);
$return = app(ConsultationReturnContext::class);
return $this->redirectToAssessmentShow($request, $assessment, $return)
->with('success', 'Assessment saved.');
}
public function complete(Request $request, Assessment $assessment): RedirectResponse
{
$this->assertEngineEnabled($request);
$this->authorizeCapture($request);
$this->authorizeAssessment($request, $assessment);
// Persist any posted answers first (complete form can include them).
if ($request->has('answers') || $request->has('notes')) {
$validated = $request->validate([
'answers' => ['nullable', 'array'],
'notes' => ['nullable', 'string', 'max:10000'],
]);
$this->assessments->saveAnswers(
$assessment,
$this->ownerRef($request),
$validated['answers'] ?? [],
$this->member($request),
$this->ownerRef($request),
array_key_exists('notes', $validated) ? $validated['notes'] : null,
);
$assessment->refresh();
}
$this->assessments->complete(
$assessment,
$this->ownerRef($request),
$this->member($request),
$this->ownerRef($request),
);
$return = app(ConsultationReturnContext::class);
return $this->redirectToAssessmentShow($request, $assessment, $return)
->with('success', 'Assessment completed.');
}
public function cancel(Request $request, Assessment $assessment): RedirectResponse
{
$this->assertEngineEnabled($request);
$this->authorizeCapture($request);
$this->authorizeAssessment($request, $assessment);
$this->assessments->cancel(
$assessment,
$this->ownerRef($request),
$this->member($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()
->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.
*/
public function fhirExport(Request $request, Assessment $assessment): StreamedResponse|JsonResponse
{
$this->assertEngineEnabled($request);
$this->authorizeAbility($request, 'assessments.view');
$this->authorizeAssessment($request, $assessment);
$bundle = $this->fhir->exportBundle($assessment);
$filename = 'assessment-'.$assessment->uuid.'-fhir.json';
if ($request->wantsJson() && ! $request->boolean('download')) {
return response()->json($bundle);
}
return response()->streamDownload(
function () use ($bundle) {
echo json_encode($bundle, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
},
$filename,
['Content-Type' => 'application/fhir+json'],
);
}
protected function assertEngineEnabled(Request $request): void
{
abort_unless(
$this->features->enabled($this->organization($request), CareFeatures::ASSESSMENTS_ENGINE),
404,
);
$this->features->ensureAssessmentCatalog();
}
protected function authorizeCapture(Request $request): void
{
abort_unless($this->canCapture($request), 403);
}
protected function canCapture(Request $request): bool
{
$member = $this->member($request);
return $this->permissions->can($member, 'assessments.capture')
|| $this->permissions->can($member, 'assessments.manage');
}
protected function authorizePatient(Request $request, Patient $patient): void
{
$this->authorizeOwner($request, $patient);
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
if ($request->attributes->get('care.assessments.skip_patient_branch')) {
return;
}
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $patient->branch_id !== $branchId) {
abort(404);
}
}
protected function authorizeConsultation(Request $request, Consultation $consultation): void
{
$this->authorizeOwner($request, $consultation);
$consultation->loadMissing('visit');
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $consultation->visit->branch_id !== $branchId) {
abort(404);
}
}
protected function authorizeAssessment(Request $request, Assessment $assessment): void
{
$this->authorizeOwner($request, $assessment);
abort_unless($assessment->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $assessment->branch_id !== $branchId) {
abort(404);
}
}
/**
* @param array{consultation_uuid?: ?string, visit_uuid?: ?string} $validated
* @return array{consultation?: Consultation, visit?: Visit, actor: string}
*/
protected function resolveContext(Request $request, Patient $patient, array $validated): array
{
$context = ['actor' => $this->ownerRef($request)];
if (! empty($validated['consultation_uuid'])) {
$consultation = Consultation::query()
->where('uuid', $validated['consultation_uuid'])
->where('patient_id', $patient->id)
->firstOrFail();
$this->authorizeConsultation($request, $consultation);
$consultation->loadMissing('visit');
$context['consultation'] = $consultation;
$context['visit'] = $consultation->visit;
} elseif (! empty($validated['visit_uuid'])) {
$visit = Visit::query()
->where('uuid', $validated['visit_uuid'])
->where('patient_id', $patient->id)
->firstOrFail();
$this->authorizeOwner($request, $visit);
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $visit->branch_id !== $branchId) {
abort(404);
}
$context['visit'] = $visit;
}
return $context;
}
}