Deploy Ladill Care / deploy (push) Successful in 40s
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>
508 lines
19 KiB
PHP
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;
|
|
}
|
|
}
|