feat(assessments): layered clinical assessment engine end-to-end
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:
isaacclad
2026-07-16 22:58:09 +00:00
parent 8896088425
commit 2ce4bc8993
94 changed files with 13430 additions and 56 deletions
@@ -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;
}
}
@@ -0,0 +1,191 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\ClinicalPathway;
use App\Models\Consultation;
use App\Models\Patient;
use App\Models\PatientPathway;
use App\Services\Care\CareFeatures;
use App\Services\Care\CarePermissions;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PathwayService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PathwayController extends Controller
{
use ScopesApiToAccount;
public function __construct(
protected PathwayService $pathways,
protected CareFeatures $features,
protected CarePermissions $permissions,
) {}
public function catalog(Request $request): JsonResponse
{
$this->assertEngineEnabled($request);
$this->authorizeAbility($request, 'assessments.view');
$pathways = ClinicalPathway::query()
->active()
->with('templates')
->orderBy('sort_order')
->get();
return response()->json(['data' => $pathways]);
}
public function index(Request $request, Patient $patient): JsonResponse
{
$this->assertEngineEnabled($request);
$this->authorizeAbility($request, 'assessments.view');
$this->authorizePatient($request, $patient);
$active = $this->pathways->activeFor($patient)->load('pathway.templates');
$history = PatientPathway::query()
->owned($this->ownerRef($request))
->where('patient_id', $patient->id)
->with('pathway')
->orderByDesc('activated_at')
->limit(50)
->get();
return response()->json([
'active' => $active->map(fn (PatientPathway $pp) => $this->serializePatientPathway($pp)),
'history' => $history->map(fn (PatientPathway $pp) => $this->serializePatientPathway($pp)),
]);
}
public function store(Request $request, Patient $patient): JsonResponse
{
$this->assertEngineEnabled($request);
$this->authorizeAbility($request, 'pathways.manage');
$this->authorizePatient($request, $patient);
$validated = $request->validate([
'pathway_code' => ['required', 'string', 'max:100'],
'consultation_uuid' => ['nullable', 'uuid'],
'activation_diagnosis_text' => ['nullable', 'string', 'max:500'],
]);
$pathway = ClinicalPathway::findByCode($validated['pathway_code']);
abort_unless($pathway, 422, 'Unknown or inactive pathway.');
$consultation = null;
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', 'diagnoses']);
}
$patientPathway = $this->pathways->activate(
$patient,
$pathway,
$this->ownerRef($request),
$this->member($request),
[
'consultation' => $consultation,
'activation_diagnosis_text' => $validated['activation_diagnosis_text'] ?? null,
'actor' => $this->ownerRef($request),
],
);
return response()->json($this->serializePatientPathway($patientPathway->load(['pathway.templates', 'assessments.template'])), 201);
}
public function deactivate(Request $request, Patient $patient, PatientPathway $patientPathway): JsonResponse
{
$this->assertEngineEnabled($request);
$this->authorizeAbility($request, 'pathways.manage');
$this->authorizePatient($request, $patient);
abort_unless($patientPathway->patient_id === $patient->id, 404);
$this->authorizeOwner($request, $patientPathway);
$updated = $this->pathways->deactivate(
$patientPathway,
$this->ownerRef($request),
$this->member($request),
$this->ownerRef($request),
);
return response()->json($this->serializePatientPathway($updated->load('pathway')));
}
public function suggestions(Request $request, Consultation $consultation): JsonResponse
{
$this->assertEngineEnabled($request);
$this->authorizeAbility($request, 'consultations.view');
$this->authorizeConsultation($request, $consultation);
$consultation->loadMissing(['diagnoses', 'patient']);
$suggestions = $this->pathways->suggest($consultation->patient, $consultation->diagnoses);
return response()->json([
'data' => $suggestions->map(fn (array $row) => [
'pathway_code' => $row['pathway_code'],
'pathway_name' => $row['pathway_name'],
'rank' => $row['rank'],
'match_reason' => $row['match_reason'],
'already_active' => $row['already_active'],
])->values()->all(),
]);
}
protected function serializePatientPathway(PatientPathway $pp): array
{
return [
'uuid' => $pp->uuid,
'status' => $pp->status,
'pathway_code' => $pp->pathway?->code,
'pathway_name' => $pp->pathway?->name,
'activated_at' => $pp->activated_at?->toIso8601String(),
'activation_diagnosis_text' => $pp->activation_diagnosis_text,
'resolved_at' => $pp->resolved_at?->toIso8601String(),
'assessments' => $pp->relationLoaded('assessments')
? $pp->assessments->map(fn ($a) => [
'uuid' => $a->uuid,
'status' => $a->status,
'template_code' => $a->template?->code,
])->values()->all()
: null,
];
}
protected function assertEngineEnabled(Request $request): void
{
abort_unless(
$this->features->enabled($this->organization($request), CareFeatures::ASSESSMENTS_ENGINE),
404,
);
}
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);
}
}
}