Deploy Ladill Care / deploy (push) Successful in 50s
Only POST existed on that path, so navigating to the natural collection URL 404'd. Serve the patient assessments index with consultation return context, and cover it with a doctor feature test. Co-authored-by: Cursor <cursoragent@cursor.com>
424 lines
15 KiB
PHP
424 lines
15 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);
|
|
|
|
return $this->index($request, $consultation->patient);
|
|
}
|
|
|
|
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();
|
|
|
|
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'),
|
|
]);
|
|
}
|
|
|
|
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);
|
|
|
|
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']);
|
|
$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.assessments.show', array_filter([
|
|
'assessment' => $assessment,
|
|
...$return->query($consultation),
|
|
]))
|
|
->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 redirect()
|
|
->route('care.assessments.show', array_filter([
|
|
'assessment' => $assessment,
|
|
...$return->routeQuery($request),
|
|
]))
|
|
->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 redirect()
|
|
->route('care.assessments.show', array_filter([
|
|
'assessment' => $assessment,
|
|
...$return->routeQuery($request),
|
|
]))
|
|
->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 redirect()
|
|
->route('care.assessments.index', $assessment->patient)
|
|
->with('success', 'Assessment cancelled.');
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
$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;
|
|
}
|
|
}
|