feat(assessments): layered clinical assessment engine end-to-end
Deploy Ladill Care / deploy (push) Successful in 1m26s
Deploy Ladill Care / deploy (push) Successful in 1m26s
Add a template-driven assessment system with universal intake, clinical pathways, disease instruments (stroke MVP + extended, diabetes, and ten specialty packs), scoring, patient outcome trends, REST/API + FHIR export, and Enterprise org-level assessment analytics. Seed packs and design/licensing docs ship for deploy and pre-GA review.
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||
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\FhirAssessmentExporter;
|
||||
use App\Services\Care\OrganizationResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AssessmentController extends Controller
|
||||
{
|
||||
use ScopesApiToAccount;
|
||||
|
||||
public function __construct(
|
||||
protected AssessmentService $assessments,
|
||||
protected CareFeatures $features,
|
||||
protected CarePermissions $permissions,
|
||||
protected FhirAssessmentExporter $fhir,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Patient $patient): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
$this->authorizePatient($request, $patient);
|
||||
|
||||
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
$page = $this->assessments->listForPatient(
|
||||
$patient,
|
||||
$this->ownerRef($request),
|
||||
$request->only(['status', 'template_code', 'category', 'from', 'to', 'per_page']),
|
||||
$branchScope,
|
||||
);
|
||||
|
||||
return response()->json($page);
|
||||
}
|
||||
|
||||
public function templates(Request $request): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
|
||||
$templates = AssessmentTemplate::query()
|
||||
->system()
|
||||
->current()
|
||||
->with('questions')
|
||||
->orderBy('category')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return response()->json(['data' => $templates]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Patient $patient): JsonResponse
|
||||
{
|
||||
$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,
|
||||
);
|
||||
|
||||
$status = $assessment->wasRecentlyCreated ? 201 : 200;
|
||||
|
||||
return response()->json($this->serializeAssessment($assessment), $status);
|
||||
}
|
||||
|
||||
public function storeForConsultation(Request $request, Consultation $consultation): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizeConsultation($request, $consultation);
|
||||
$consultation->loadMissing(['patient', 'visit']);
|
||||
$this->authorizePatient($request, $consultation->patient);
|
||||
|
||||
$validated = $request->validate([
|
||||
'template_code' => ['required', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
$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 response()->json($this->serializeAssessment($assessment), $assessment->wasRecentlyCreated ? 201 : 200);
|
||||
}
|
||||
|
||||
public function show(Request $request, Assessment $assessment): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
return response()->json($this->serializeAssessment(
|
||||
$assessment->load(['template.questions', 'answers.question', 'score', 'patient', 'consultation', 'visit'])
|
||||
));
|
||||
}
|
||||
|
||||
public function update(Request $request, Assessment $assessment): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
$validated = $request->validate([
|
||||
'answers' => ['nullable', 'array'],
|
||||
'notes' => ['nullable', 'string', 'max:10000'],
|
||||
]);
|
||||
|
||||
$updated = $this->assessments->saveAnswers(
|
||||
$assessment,
|
||||
$this->ownerRef($request),
|
||||
$validated['answers'] ?? [],
|
||||
$this->member($request),
|
||||
$this->ownerRef($request),
|
||||
array_key_exists('notes', $validated) ? $validated['notes'] : null,
|
||||
);
|
||||
|
||||
return response()->json($this->serializeAssessment($updated));
|
||||
}
|
||||
|
||||
public function complete(Request $request, Assessment $assessment): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
$completed = $this->assessments->complete(
|
||||
$assessment,
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
$this->ownerRef($request),
|
||||
);
|
||||
|
||||
return response()->json($this->serializeAssessment($completed));
|
||||
}
|
||||
|
||||
public function cancel(Request $request, Assessment $assessment): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
$cancelled = $this->assessments->cancel(
|
||||
$assessment,
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
$this->ownerRef($request),
|
||||
);
|
||||
|
||||
return response()->json($this->serializeAssessment($cancelled));
|
||||
}
|
||||
|
||||
public function fhir(Request $request, Assessment $assessment): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
return response()->json(
|
||||
$this->fhir->exportBundle($assessment),
|
||||
200,
|
||||
['Content-Type' => 'application/fhir+json'],
|
||||
);
|
||||
}
|
||||
|
||||
protected function serializeAssessment(Assessment $assessment): array
|
||||
{
|
||||
$assessment->loadMissing(['template', 'answers.question', 'score', 'patient']);
|
||||
|
||||
$answers = [];
|
||||
foreach ($assessment->answers as $answer) {
|
||||
if ($answer->question) {
|
||||
$answers[$answer->question->code] = $answer->authoritativeValue($answer->question->answer_type);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'uuid' => $assessment->uuid,
|
||||
'status' => $assessment->status,
|
||||
'template_code' => $assessment->template?->code,
|
||||
'template_name' => $assessment->template?->name,
|
||||
'template_version' => $assessment->template?->version,
|
||||
'patient_uuid' => $assessment->patient?->uuid,
|
||||
'consultation_uuid' => $assessment->consultation?->uuid,
|
||||
'visit_uuid' => $assessment->visit?->uuid,
|
||||
'assessed_at' => $assessment->assessed_at?->toIso8601String(),
|
||||
'completed_at' => $assessment->completed_at?->toIso8601String(),
|
||||
'notes' => $assessment->notes,
|
||||
'answers' => $answers,
|
||||
'score' => $assessment->score ? [
|
||||
'total_score' => $assessment->score->total_score,
|
||||
'max_score' => $assessment->score->max_score,
|
||||
'subscores' => $assessment->score->subscores,
|
||||
'severity_label' => $assessment->score->severity_label,
|
||||
'computed_at' => $assessment->score->computed_at?->toIso8601String(),
|
||||
] : null,
|
||||
'template' => $assessment->template,
|
||||
];
|
||||
}
|
||||
|
||||
protected function assertEngineEnabled(Request $request): void
|
||||
{
|
||||
abort_unless(
|
||||
$this->features->enabled($this->organization($request), CareFeatures::ASSESSMENTS_ENGINE),
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
protected function authorizeCapture(Request $request): void
|
||||
{
|
||||
$member = $this->member($request);
|
||||
abort_unless(
|
||||
$this->permissions->can($member, 'assessments.capture')
|
||||
|| $this->permissions->can($member, 'assessments.manage'),
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user