diff --git a/DEPLOY.md b/DEPLOY.md index 40a57b2..afa913f 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -71,6 +71,22 @@ curl -s -o /dev/null -w '%{http_code}\n' https://care.ladill.com/login curl -s https://care.ladill.com/api/health ``` +### Clinical assessment content packs + +After migrate (and on each release that adds packs under `database/data/assessments/`): + +```bash +cd /var/www/ladill-care/current +php artisan db:seed --class=Database\\Seeders\\AssessmentTemplateSeeder --force +php artisan db:seed --class=Database\\Seeders\\ClinicalPathwaySeeder --force +# or full: php artisan db:seed --force +``` + +Packs are platform-global (no per-tenant copy). Enable the engine per organization via +`settings.rollout.assessments_engine = true` (see design doc / `CareFeatures`). +Pathway content lives under `database/data/pathways/`; assessment instruments under +`database/data/assessments/`. + ## 7. Optional queue worker ```bash diff --git a/app/Contracts/Care/ScoresAssessment.php b/app/Contracts/Care/ScoresAssessment.php new file mode 100644 index 0000000..c6743cf --- /dev/null +++ b/app/Contracts/Care/ScoresAssessment.php @@ -0,0 +1,13 @@ +, severity_label: ?string} + */ + public function score(Assessment $assessment): array; +} diff --git a/app/Http/Controllers/Api/AssessmentController.php b/app/Http/Controllers/Api/AssessmentController.php new file mode 100644 index 0000000..77bbeda --- /dev/null +++ b/app/Http/Controllers/Api/AssessmentController.php @@ -0,0 +1,332 @@ +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; + } +} diff --git a/app/Http/Controllers/Api/PathwayController.php b/app/Http/Controllers/Api/PathwayController.php new file mode 100644 index 0000000..52cbc0a --- /dev/null +++ b/app/Http/Controllers/Api/PathwayController.php @@ -0,0 +1,191 @@ +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); + } + } +} diff --git a/app/Http/Controllers/Care/AssessmentController.php b/app/Http/Controllers/Care/AssessmentController.php new file mode 100644 index 0000000..adf66d8 --- /dev/null +++ b/app/Http/Controllers/Care/AssessmentController.php @@ -0,0 +1,379 @@ +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']), + ]); + } + + 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 redirect() + ->route('care.assessments.show', $assessment) + ->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 redirect() + ->route('care.assessments.show', $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 view('care.assessments.show', [ + 'assessment' => $assessment, + 'answersByCode' => $answersByCode, + 'canEdit' => $canEdit, + 'statuses' => config('care.assessment_statuses'), + ]); + } + + 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 redirect() + ->route('care.assessments.show', $assessment) + ->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 redirect() + ->route('care.assessments.show', $assessment) + ->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, + ); + } + + 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; + } +} diff --git a/app/Http/Controllers/Care/ConsultationController.php b/app/Http/Controllers/Care/ConsultationController.php index d63c853..9b6ae29 100644 --- a/app/Http/Controllers/Care/ConsultationController.php +++ b/app/Http/Controllers/Care/ConsultationController.php @@ -7,8 +7,13 @@ use App\Http\Controllers\Care\Concerns\ScopesToAccount; use App\Models\Consultation; use App\Models\InvestigationType; use App\Models\Practitioner; +use App\Models\Assessment; +use App\Models\AssessmentTemplate; +use App\Services\Care\CareFeatures; +use App\Services\Care\CarePermissions; use App\Services\Care\ConsultationService; use App\Services\Care\OrganizationResolver; +use App\Services\Care\PathwayService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -32,25 +37,127 @@ class ConsultationController extends Controller 'investigationRequests.investigationType', 'prescriptions.items', ]); - $canManage = app(\App\Services\Care\CarePermissions::class) - ->can($this->member($request), 'consultations.manage'); - $canVitals = app(\App\Services\Care\CarePermissions::class) - ->can($this->member($request), 'vitals.manage'); - $canRequestInvestigations = app(\App\Services\Care\CarePermissions::class) - ->can($this->member($request), 'investigations.request'); - $canPrescribe = app(\App\Services\Care\CarePermissions::class) - ->can($this->member($request), 'prescriptions.manage'); - $canGenerateBill = app(\App\Services\Care\CarePermissions::class) - ->can($this->member($request), 'bills.manage'); + $permissions = app(CarePermissions::class); + $member = $this->member($request); + $organization = $this->organization($request); + + $canManage = $permissions->can($member, 'consultations.manage'); + $canVitals = $permissions->can($member, 'vitals.manage'); + $canRequestInvestigations = $permissions->can($member, 'investigations.request'); + $canPrescribe = $permissions->can($member, 'prescriptions.manage'); + $canGenerateBill = $permissions->can($member, 'bills.manage'); + + $assessmentsEnabled = app(CareFeatures::class)->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE); + $canViewAssessments = $assessmentsEnabled && $permissions->can($member, 'assessments.view'); + $canCaptureAssessment = $assessmentsEnabled && ( + $permissions->can($member, 'assessments.capture') + || $permissions->can($member, 'assessments.manage') + ); + + $consultationAssessments = collect(); + $startableTemplates = collect(); + $universalTemplate = null; + $universalAssessment = null; + $canCaptureUniversal = false; + $canManagePathways = $assessmentsEnabled && $permissions->can($member, 'pathways.manage'); + $pathwaySuggestions = collect(); + $activePathways = collect(); + + if ($canViewAssessments) { + $consultationAssessments = Assessment::query() + ->owned($this->ownerRef($request)) + ->where('consultation_id', $consultation->id) + ->with(['template', 'answers.question']) + ->orderByDesc('created_at') + ->get(); + + $universalTemplate = AssessmentTemplate::currentSystemByCode('universal_intake'); + if ($universalTemplate) { + $universalAssessment = $consultationAssessments + ->first(fn (Assessment $a) => $a->template_id === $universalTemplate->id) + ?? Assessment::query() + ->owned($this->ownerRef($request)) + ->where('patient_id', $consultation->patient_id) + ->where('template_id', $universalTemplate->id) + ->whereIn('status', [Assessment::STATUS_DRAFT, Assessment::STATUS_COMPLETED]) + ->with(['template', 'answers.question']) + ->orderByDesc('created_at') + ->first(); + } + + if ($canCaptureAssessment) { + $startableTemplates = AssessmentTemplate::query() + ->system() + ->current() + ->orderBy('category') + ->orderBy('name') + ->get() + // Universal has a dedicated CTA; keep other templates in the general list. + ->reject(fn (AssessmentTemplate $t) => $t->code === 'universal_intake') + ->values(); + + if ($universalTemplate) { + try { + app(\App\Services\Care\AssessmentService::class) + ->assertCaptureAllowed($member, $universalTemplate); + $canCaptureUniversal = true; + } catch (\Throwable) { + $canCaptureUniversal = false; + } + } + } + + $pathwayService = app(PathwayService::class); + $activePathways = $pathwayService->activeFor($consultation->patient) + ->load(['pathway.templates', 'assessments.template', 'assessments.score']); + // Suggestions from **persisted** diagnoses only (after save). + $pathwaySuggestions = $pathwayService->suggest( + $consultation->patient, + $consultation->diagnoses, + ); + + // Instruments for active pathways (e.g. stroke → NIHSS, mRS) with draft/complete status. + $pathwayInstruments = collect(); + foreach ($activePathways as $patientPathway) { + foreach ($patientPathway->pathway->templates as $binding) { + $template = AssessmentTemplate::currentSystemByCode($binding->template_code); + $assessment = $patientPathway->assessments + ->first(fn (Assessment $a) => $a->template?->code === $binding->template_code) + ?? $consultationAssessments + ->first(fn (Assessment $a) => $a->template?->code === $binding->template_code) + ?? ($template + ? Assessment::query() + ->owned($this->ownerRef($request)) + ->where('patient_id', $consultation->patient_id) + ->where('template_id', $template->id) + ->with(['template', 'score']) + ->orderByDesc('created_at') + ->first() + : null); + + $pathwayInstruments->push([ + 'pathway_code' => $patientPathway->pathway->code, + 'pathway_name' => $patientPathway->pathway->name, + 'template_code' => $binding->template_code, + 'template_name' => $template?->name ?? $binding->template_code, + 'required' => $binding->is_required_on_activation, + 'phase' => $binding->phase, + 'template' => $template, + 'assessment' => $assessment, + 'pack_missing' => $template === null, + ]); + } + } + } $practitioners = Practitioner::owned($this->ownerRef($request)) - ->where('organization_id', $this->organization($request)->id) + ->where('organization_id', $organization->id) ->where('is_active', true) ->orderBy('name') ->get(); $investigationTypes = InvestigationType::owned($this->ownerRef($request)) - ->where('organization_id', $this->organization($request)->id) + ->where('organization_id', $organization->id) ->where('is_active', true) ->orderBy('name') ->get(); @@ -65,6 +172,19 @@ class ConsultationController extends Controller 'canPrescribe' => $canPrescribe, 'canGenerateBill' => $canGenerateBill, 'isCompleted' => $consultation->status === Consultation::STATUS_COMPLETED, + 'assessmentsEnabled' => $assessmentsEnabled, + 'canViewAssessments' => $canViewAssessments, + 'canCaptureAssessment' => $canCaptureAssessment, + 'consultationAssessments' => $consultationAssessments, + 'startableTemplates' => $startableTemplates, + 'assessmentStatuses' => config('care.assessment_statuses'), + 'universalTemplate' => $universalTemplate, + 'universalAssessment' => $universalAssessment, + 'canCaptureUniversal' => $canCaptureUniversal, + 'canManagePathways' => $canManagePathways, + 'pathwaySuggestions' => $pathwaySuggestions, + 'activePathways' => $activePathways, + 'pathwayInstruments' => $pathwayInstruments ?? collect(), ]); } @@ -88,7 +208,16 @@ class ConsultationController extends Controller $this->ownerRef($request), ); - return back()->with('success', 'Consultation saved.'); + $message = 'Consultation saved.'; + if ($canManage && $request->boolean('suggest_pathways')) { + $consultation->load('diagnoses'); + $count = $consultation->diagnoses->filter(fn ($d) => filled($d->description) || filled($d->code))->count(); + $message = $count > 0 + ? 'Diagnoses saved — review pathway suggestions below.' + : 'Consultation saved. Add and save diagnoses to see pathway suggestions.'; + } + + return back()->with('success', $message); } public function complete(Request $request, Consultation $consultation): RedirectResponse diff --git a/app/Http/Controllers/Care/OutcomeController.php b/app/Http/Controllers/Care/OutcomeController.php new file mode 100644 index 0000000..7a5e4b4 --- /dev/null +++ b/app/Http/Controllers/Care/OutcomeController.php @@ -0,0 +1,109 @@ +assertEngineEnabled($request); + $this->authorizeAbility($request, 'assessments.view'); + $this->authorizePatient($request, $patient); + + $branchScope = app(OrganizationResolver::class)->branchScope($this->member($request)); + $ownerRef = $this->ownerRef($request); + + $outcomeSeries = $this->trends->outcomeSeries($patient, $ownerRef, $branchScope); + $scores = $this->trends->instrumentScores($patient, $ownerRef, $branchScope); + $vitals = $this->trends->recentVitals($patient, $ownerRef); + + $canCapture = $this->permissions->can($this->member($request), 'assessments.capture') + || $this->permissions->can($this->member($request), 'assessments.manage'); + + // Org-level multi-patient analytics is Enterprise (KD-18); surface a notice only. + $canOrgAnalytics = $this->plans->isEnterprise($this->organization($request)); + + return view('care.outcomes.index', [ + 'patient' => $patient, + 'outcomeSeries' => $outcomeSeries, + 'scores' => $scores, + 'vitals' => $vitals, + 'canCapture' => $canCapture, + 'canOrgAnalytics' => $canOrgAnalytics, + 'qolSeries' => $this->trends->numericOutcomeSeries($patient, $ownerRef, 'quality_of_life', $branchScope), + 'painSeries' => $this->trends->numericOutcomeSeries($patient, $ownerRef, 'pain_score', $branchScope), + 'statuses' => config('care.assessment_statuses'), + ]); + } + + /** + * Start a new outcome_core draft for the patient. + */ + public function store(Request $request, Patient $patient): RedirectResponse + { + $this->assertEngineEnabled($request); + abort_unless( + $this->permissions->can($this->member($request), 'assessments.capture') + || $this->permissions->can($this->member($request), 'assessments.manage'), + 403, + ); + $this->authorizePatient($request, $patient); + + $assessment = $this->assessments->start( + $patient, + OutcomeTrendService::OUTCOME_TEMPLATE_CODE, + $this->ownerRef($request), + $this->member($request), + ['actor' => $this->ownerRef($request)], + ); + + return redirect() + ->route('care.assessments.show', $assessment) + ->with('success', 'Outcome assessment started.'); + } + + 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); + } + } +} diff --git a/app/Http/Controllers/Care/PathwayController.php b/app/Http/Controllers/Care/PathwayController.php new file mode 100644 index 0000000..8254ea9 --- /dev/null +++ b/app/Http/Controllers/Care/PathwayController.php @@ -0,0 +1,176 @@ +assertPathwaysEnabled($request); + $this->authorizeAbility($request, 'assessments.view'); + $this->authorizePatient($request, $patient); + + $active = $this->pathways->activeFor($patient); + $history = PatientPathway::query() + ->owned($this->ownerRef($request)) + ->where('patient_id', $patient->id) + ->with('pathway') + ->orderByDesc('activated_at') + ->paginate(20); + + $catalog = ClinicalPathway::query()->active()->orderBy('sort_order')->get(); + $canManage = $this->permissions->can($this->member($request), 'pathways.manage'); + + return view('care.pathways.index', [ + 'patient' => $patient, + 'active' => $active, + 'history' => $history, + 'catalog' => $catalog, + 'canManage' => $canManage, + 'statuses' => config('care.patient_pathway_statuses'), + ]); + } + + public function store(Request $request, Patient $patient): RedirectResponse + { + $this->assertPathwaysEnabled($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 redirect() + ->route('care.pathways.index', $patient) + ->with('success', "Pathway “{$pathway->name}” activated."); + } + + public function deactivate(Request $request, Patient $patient, PatientPathway $patientPathway): RedirectResponse + { + $this->assertPathwaysEnabled($request); + $this->authorizeAbility($request, 'pathways.manage'); + $this->authorizePatient($request, $patient); + abort_unless($patientPathway->patient_id === $patient->id, 404); + $this->authorizeOwner($request, $patientPathway); + + $this->pathways->deactivate( + $patientPathway, + $this->ownerRef($request), + $this->member($request), + $this->ownerRef($request), + ); + + return back()->with('success', 'Pathway deactivated.'); + } + + public function suggestions(Request $request, Consultation $consultation): JsonResponse|RedirectResponse + { + $this->assertPathwaysEnabled($request); + $this->authorizeAbility($request, 'consultations.view'); + $this->authorizeConsultation($request, $consultation); + + $consultation->loadMissing(['diagnoses', 'patient']); + $suggestions = $this->pathways->suggest($consultation->patient, $consultation->diagnoses); + + $payload = [ + '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(), + ]; + + if ($request->wantsJson() || $request->ajax()) { + return response()->json($payload); + } + + // HTML fallthrough not used — consultation show embeds suggestions server-side. + return back()->with('pathway_suggestions', $payload['data']); + } + + protected function assertPathwaysEnabled(Request $request): void + { + $org = $this->organization($request); + // Pathways require assessments engine; suggestions flag is optional UX. + abort_unless( + $this->features->enabled($org, 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); + } + } +} diff --git a/app/Http/Controllers/Care/PatientController.php b/app/Http/Controllers/Care/PatientController.php index 9576d01..ab4a807 100644 --- a/app/Http/Controllers/Care/PatientController.php +++ b/app/Http/Controllers/Care/PatientController.php @@ -6,6 +6,8 @@ use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; use App\Models\Branch; use App\Models\Patient; +use App\Services\Care\CareFeatures; +use App\Services\Care\CarePermissions; use App\Services\Care\OrganizationResolver; use App\Services\Care\PatientService; use Illuminate\Http\RedirectResponse; @@ -91,13 +93,41 @@ class PatientController extends Controller $this->authorizePatient($request, $patient); $dashboard = $this->patients->dashboard($patient); - $canManage = app(\App\Services\Care\CarePermissions::class) - ->can($this->member($request), 'patients.manage'); + $permissions = app(CarePermissions::class); + $member = $this->member($request); + $organization = $this->organization($request); + $canManage = $permissions->can($member, 'patients.manage'); $credential = app(\App\Services\Messaging\MessagingCredentialsService::class) - ->forOrganization($this->organization($request)); + ->forOrganization($organization); $suiteEmailReady = app(\App\Services\Billing\PlatformEmailClient::class)->isConfigured(); $suiteSmsReady = app(\App\Services\Billing\PlatformSmsClient::class)->isConfigured(); + $assessmentsEnabled = app(CareFeatures::class)->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE); + $canViewAssessments = $assessmentsEnabled && $permissions->can($member, 'assessments.view'); + $canCaptureAssessment = $assessmentsEnabled && ( + $permissions->can($member, 'assessments.capture') + || $permissions->can($member, 'assessments.manage') + ); + $recentAssessments = collect(); + $latestUniversalIntake = null; + if ($canViewAssessments) { + $branchScope = app(OrganizationResolver::class)->branchScope($member); + $recentAssessments = $patient->assessments() + ->with('template') + ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) + ->orderByDesc('created_at') + ->limit(10) + ->get(); + + $latestUniversalIntake = $patient->assessments() + ->with(['template', 'answers.question']) + ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) + ->whereHas('template', fn ($q) => $q->where('code', 'universal_intake')) + ->whereIn('status', [\App\Models\Assessment::STATUS_DRAFT, \App\Models\Assessment::STATUS_COMPLETED]) + ->orderByDesc('created_at') + ->first(); + } + return view('care.patients.show', array_merge($dashboard, [ 'canManage' => $canManage, 'messagingSmsReady' => $suiteSmsReady || $credential->hasValidSms(), @@ -109,6 +139,12 @@ class PatientController extends Controller 'genders' => config('care.genders'), 'allergySeverities' => config('care.allergy_severities'), 'documentTypes' => config('care.document_types'), + 'assessmentsEnabled' => $assessmentsEnabled, + 'canViewAssessments' => $canViewAssessments, + 'canCaptureAssessment' => $canCaptureAssessment, + 'recentAssessments' => $recentAssessments, + 'latestUniversalIntake' => $latestUniversalIntake, + 'assessmentStatuses' => config('care.assessment_statuses'), ])); } diff --git a/app/Http/Controllers/Care/ReportController.php b/app/Http/Controllers/Care/ReportController.php index ed06fe4..198dd65 100644 --- a/app/Http/Controllers/Care/ReportController.php +++ b/app/Http/Controllers/Care/ReportController.php @@ -5,7 +5,9 @@ namespace App\Http\Controllers\Care; use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; use App\Models\Branch; +use App\Services\Care\CareFeatures; use App\Services\Care\OrganizationResolver; +use App\Services\Care\PlanService; use App\Services\Care\ReportService; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -47,14 +49,7 @@ class ReportController extends Controller [$from, $to] = $this->dateRange($request); - $data = match ($type) { - 'patients' => $this->reports->patientsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId), - 'appointments' => $this->reports->appointmentsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId), - 'laboratory' => $this->reports->laboratoryReport($this->ownerRef($request), $organization->id, $from, $to, $branchId), - 'finance' => $this->reports->financeReport($this->ownerRef($request), $organization->id, $from, $to, $branchId), - 'clinical' => ['diagnoses' => $this->reports->clinicalReport($this->ownerRef($request), $organization->id, $from, $to)], - default => abort(404), - }; + $data = $this->reportData($request, $type, $organization->id, $from, $to, $branchId); $branches = Branch::owned($this->ownerRef($request)) ->where('organization_id', $organization->id) @@ -87,14 +82,7 @@ class ReportController extends Controller $branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope; [$from, $to] = $this->dateRange($request); - $data = match ($type) { - 'patients' => $this->reports->patientsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId), - 'appointments' => $this->reports->appointmentsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId), - 'laboratory' => $this->reports->laboratoryReport($this->ownerRef($request), $organization->id, $from, $to, $branchId), - 'finance' => $this->reports->financeReport($this->ownerRef($request), $organization->id, $from, $to, $branchId), - 'clinical' => ['diagnoses' => $this->reports->clinicalReport($this->ownerRef($request), $organization->id, $from, $to)], - default => abort(404), - }; + $data = $this->reportData($request, $type, $organization->id, $from, $to, $branchId); $filename = "care-report-{$type}-".now()->format('Y-m-d').'.csv'; @@ -105,6 +93,20 @@ class ReportController extends Controller foreach ($data['diagnoses'] as $row) { fputcsv($handle, [$row->description, $row->total]); } + } elseif ($type === 'assessments') { + fputcsv($handle, ['Section', 'Key', 'Value']); + foreach ($data['summary'] ?? [] as $key => $value) { + fputcsv($handle, ['summary', $key, $value]); + } + foreach ($data['by_template'] ?? [] as $row) { + fputcsv($handle, ['template', $row->template_code, "{$row->total} total / {$row->completed} completed"]); + } + foreach ($data['by_pathway'] ?? [] as $row) { + fputcsv($handle, ['pathway', $row->pathway_code, "{$row->total} activations"]); + } + foreach ($data['score_averages'] ?? [] as $row) { + fputcsv($handle, ['score_avg', $row->template_code, round((float) $row->avg_total, 2)]); + } } else { fputcsv($handle, ['Metric', 'Value']); foreach ($data as $key => $value) { @@ -115,10 +117,39 @@ class ReportController extends Controller }, $filename, ['Content-Type' => 'text/csv']); } + /** + * @return array + */ + protected function reportData(Request $request, string $type, int $organizationId, Carbon $from, Carbon $to, ?int $branchId): array + { + return match ($type) { + 'patients' => $this->reports->patientsReport($this->ownerRef($request), $organizationId, $from, $to, $branchId), + 'appointments' => $this->reports->appointmentsReport($this->ownerRef($request), $organizationId, $from, $to, $branchId), + 'laboratory' => $this->reports->laboratoryReport($this->ownerRef($request), $organizationId, $from, $to, $branchId), + 'finance' => $this->reports->financeReport($this->ownerRef($request), $organizationId, $from, $to, $branchId), + 'clinical' => ['diagnoses' => $this->reports->clinicalReport($this->ownerRef($request), $organizationId, $from, $to)], + 'assessments' => $this->reports->assessmentsReport($this->ownerRef($request), $organizationId, $from, $to, $branchId), + default => abort(404), + }; + } + protected function authorizeReport(Request $request, string $type): void { abort_unless(array_key_exists($type, config('care.report_types')), 404); $this->authorizeAbility($request, 'reports.finance.view'); + + if ($type === 'assessments') { + $organization = $this->organization($request); + abort_unless( + app(CareFeatures::class)->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE), + 404, + ); + abort_unless( + app(PlanService::class)->hasFeature($organization, 'assessment_analytics'), + 403, + 'Assessment analytics require an Enterprise plan.', + ); + } } /** diff --git a/app/Models/Assessment.php b/app/Models/Assessment.php new file mode 100644 index 0000000..6e779c6 --- /dev/null +++ b/app/Models/Assessment.php @@ -0,0 +1,112 @@ + 'datetime', + 'completed_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (Assessment $assessment) { + if (! $assessment->uuid) { + $assessment->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function branch(): BelongsTo + { + return $this->belongsTo(Branch::class, 'branch_id'); + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } + + public function template(): BelongsTo + { + return $this->belongsTo(AssessmentTemplate::class, 'template_id'); + } + + public function consultation(): BelongsTo + { + return $this->belongsTo(Consultation::class, 'consultation_id'); + } + + public function visit(): BelongsTo + { + return $this->belongsTo(Visit::class, 'visit_id'); + } + + public function patientPathway(): BelongsTo + { + return $this->belongsTo(PatientPathway::class, 'patient_pathway_id'); + } + + public function practitioner(): BelongsTo + { + return $this->belongsTo(Practitioner::class, 'practitioner_id'); + } + + public function answers(): HasMany + { + return $this->hasMany(AssessmentAnswer::class, 'assessment_id'); + } + + public function score(): HasOne + { + return $this->hasOne(AssessmentScore::class, 'assessment_id'); + } + + public function isDraft(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } +} diff --git a/app/Models/AssessmentAnswer.php b/app/Models/AssessmentAnswer.php new file mode 100644 index 0000000..eb11803 --- /dev/null +++ b/app/Models/AssessmentAnswer.php @@ -0,0 +1,63 @@ + 'decimal:4', + 'value_boolean' => 'boolean', + 'value_date' => 'datetime', + 'value_json' => 'array', + ]; + } + + public function assessment(): BelongsTo + { + return $this->belongsTo(Assessment::class, 'assessment_id'); + } + + public function question(): BelongsTo + { + return $this->belongsTo(AssessmentQuestion::class, 'question_id'); + } + + /** + * Authoritative value for the given answer type (unused columns must stay null at write time). + */ + public function authoritativeValue(?string $answerType = null): mixed + { + $type = $answerType ?? $this->question?->answer_type; + + return match ($type) { + AssessmentQuestion::TYPE_TEXT, + AssessmentQuestion::TYPE_TEXTAREA, + AssessmentQuestion::TYPE_SINGLE_CHOICE => $this->value_text, + AssessmentQuestion::TYPE_NUMBER, + AssessmentQuestion::TYPE_INTEGER, + AssessmentQuestion::TYPE_SCALE, + AssessmentQuestion::TYPE_SCORE_ITEM, + AssessmentQuestion::TYPE_CALCULATED => $this->value_number, + AssessmentQuestion::TYPE_BOOLEAN => $this->value_boolean, + AssessmentQuestion::TYPE_DATE, + AssessmentQuestion::TYPE_DATETIME => $this->value_date, + AssessmentQuestion::TYPE_MULTI_CHOICE => $this->value_json, + default => $this->value_text ?? $this->value_number ?? $this->value_boolean ?? $this->value_date ?? $this->value_json, + }; + } +} diff --git a/app/Models/AssessmentQuestion.php b/app/Models/AssessmentQuestion.php new file mode 100644 index 0000000..8462da0 --- /dev/null +++ b/app/Models/AssessmentQuestion.php @@ -0,0 +1,61 @@ + 'array', + 'validation' => 'array', + 'is_required' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + public function template(): BelongsTo + { + return $this->belongsTo(AssessmentTemplate::class, 'template_id'); + } + + public function answers(): HasMany + { + return $this->hasMany(AssessmentAnswer::class, 'question_id'); + } +} diff --git a/app/Models/AssessmentScore.php b/app/Models/AssessmentScore.php new file mode 100644 index 0000000..668e30c --- /dev/null +++ b/app/Models/AssessmentScore.php @@ -0,0 +1,34 @@ + 'decimal:4', + 'max_score' => 'decimal:4', + 'subscores' => 'array', + 'computed_at' => 'datetime', + ]; + } + + public function assessment(): BelongsTo + { + return $this->belongsTo(Assessment::class, 'assessment_id'); + } +} diff --git a/app/Models/AssessmentTemplate.php b/app/Models/AssessmentTemplate.php new file mode 100644 index 0000000..04c4daa --- /dev/null +++ b/app/Models/AssessmentTemplate.php @@ -0,0 +1,95 @@ + 'integer', + 'is_current' => 'boolean', + 'is_active' => 'boolean', + 'meta' => 'array', + ]; + } + + protected static function booted(): void + { + static::creating(function (AssessmentTemplate $template) { + if (! $template->uuid) { + $template->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function questions(): HasMany + { + return $this->hasMany(AssessmentQuestion::class, 'template_id')->orderBy('sort_order'); + } + + public function assessments(): HasMany + { + return $this->hasMany(Assessment::class, 'template_id'); + } + + public function scopeSystem(Builder $query): Builder + { + return $query->whereNull($this->getTable().'.organization_id'); + } + + public function scopeCurrent(Builder $query): Builder + { + return $query->where($this->getTable().'.is_current', true) + ->where($this->getTable().'.is_active', true); + } + + /** + * Resolve the current system template for a stable code (e.g. nihss, universal_intake). + */ + public static function currentSystemByCode(string $code): ?self + { + return static::query() + ->system() + ->current() + ->where('code', $code) + ->first(); + } +} diff --git a/app/Models/ClinicalPathway.php b/app/Models/ClinicalPathway.php new file mode 100644 index 0000000..700e3b4 --- /dev/null +++ b/app/Models/ClinicalPathway.php @@ -0,0 +1,66 @@ + 'array', + 'meta' => 'array', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + protected static function booted(): void + { + static::creating(function (ClinicalPathway $pathway) { + if (! $pathway->uuid) { + $pathway->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function templates(): HasMany + { + return $this->hasMany(PathwayTemplate::class, 'pathway_id')->orderBy('sort_order'); + } + + public function patientPathways(): HasMany + { + return $this->hasMany(PatientPathway::class, 'pathway_id'); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + public static function findByCode(string $code): ?self + { + return static::query()->where('code', $code)->where('is_active', true)->first(); + } +} diff --git a/app/Models/Consultation.php b/app/Models/Consultation.php index b31e7a0..dfd5e80 100644 --- a/app/Models/Consultation.php +++ b/app/Models/Consultation.php @@ -78,6 +78,11 @@ class Consultation extends Model return $this->hasMany(Diagnosis::class, 'consultation_id'); } + public function assessments(): HasMany + { + return $this->hasMany(Assessment::class, 'consultation_id'); + } + public function documents(): HasMany { return $this->hasMany(ConsultationDocument::class, 'consultation_id'); diff --git a/app/Models/PathwayTemplate.php b/app/Models/PathwayTemplate.php new file mode 100644 index 0000000..ec39b7d --- /dev/null +++ b/app/Models/PathwayTemplate.php @@ -0,0 +1,35 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + } + + public function pathway(): BelongsTo + { + return $this->belongsTo(ClinicalPathway::class, 'pathway_id'); + } +} diff --git a/app/Models/Patient.php b/app/Models/Patient.php index 6f517a3..8f68b8e 100644 --- a/app/Models/Patient.php +++ b/app/Models/Patient.php @@ -99,6 +99,16 @@ class Patient extends Model return $this->hasMany(Visit::class, 'patient_id'); } + public function assessments(): HasMany + { + return $this->hasMany(Assessment::class, 'patient_id'); + } + + public function pathways(): HasMany + { + return $this->hasMany(PatientPathway::class, 'patient_id'); + } + public function investigationRequests(): HasMany { return $this->hasMany(InvestigationRequest::class, 'patient_id'); diff --git a/app/Models/PatientPathway.php b/app/Models/PatientPathway.php new file mode 100644 index 0000000..b303e5e --- /dev/null +++ b/app/Models/PatientPathway.php @@ -0,0 +1,81 @@ + 'datetime', + 'resolved_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (PatientPathway $row) { + if (! $row->uuid) { + $row->uuid = (string) Str::uuid(); + } + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function patient(): BelongsTo + { + return $this->belongsTo(Patient::class, 'patient_id'); + } + + public function pathway(): BelongsTo + { + return $this->belongsTo(ClinicalPathway::class, 'pathway_id'); + } + + public function activationConsultation(): BelongsTo + { + return $this->belongsTo(Consultation::class, 'activation_consultation_id'); + } + + public function assessments(): HasMany + { + return $this->hasMany(Assessment::class, 'patient_pathway_id'); + } + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE; + } +} diff --git a/app/Models/Visit.php b/app/Models/Visit.php index 3d023ca..06f928f 100644 --- a/app/Models/Visit.php +++ b/app/Models/Visit.php @@ -74,6 +74,11 @@ class Visit extends Model return $this->hasMany(Consultation::class, 'visit_id'); } + public function assessments(): HasMany + { + return $this->hasMany(Assessment::class, 'visit_id'); + } + public function bill(): HasOne { return $this->hasOne(Bill::class, 'visit_id'); diff --git a/app/Services/Care/AssessmentService.php b/app/Services/Care/AssessmentService.php new file mode 100644 index 0000000..591dcd9 --- /dev/null +++ b/app/Services/Care/AssessmentService.php @@ -0,0 +1,570 @@ + 'Unknown, inactive, or non-current assessment template.', + ]); + } + + $this->assertCaptureAllowed($member, $template); + + $consultation = $context['consultation'] ?? null; + $visit = $context['visit'] ?? $consultation?->visit; + $patientPathway = $context['patient_pathway'] ?? null; + $actor = $context['actor'] ?? null; + + return DB::transaction(function () use ($patient, $template, $ownerRef, $consultation, $visit, $patientPathway, $context, $actor) { + $query = Assessment::query() + ->where('patient_id', $patient->id) + ->where('template_id', $template->id) + ->where('status', Assessment::STATUS_DRAFT) + ->lockForUpdate(); + + if ($consultation) { + $query->where('consultation_id', $consultation->id); + } else { + $query->whereNull('consultation_id'); + } + + $existing = $query->first(); + if ($existing) { + if ($patientPathway && ! $existing->patient_pathway_id) { + $existing->update(['patient_pathway_id' => $patientPathway->id]); + } + + return $existing->load(['template.questions', 'answers.question', 'patient', 'consultation', 'visit', 'score']); + } + + $branchId = $visit?->branch_id ?? $patient->branch_id; + + $assessment = Assessment::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $patient->organization_id, + 'branch_id' => $branchId, + 'patient_id' => $patient->id, + 'template_id' => $template->id, + 'consultation_id' => $consultation?->id, + 'visit_id' => $visit?->id, + 'patient_pathway_id' => $patientPathway?->id, + 'practitioner_id' => $context['practitioner_id'] ?? $consultation?->practitioner_id, + 'status' => Assessment::STATUS_DRAFT, + 'started_by' => $actor, + ]); + + AuditLogger::record( + $ownerRef, + 'assessment.started', + $patient->organization_id, + $actor, + Assessment::class, + $assessment->id, + ['template_code' => $template->code, 'template_version' => $template->version], + ); + + return $assessment->load(['template.questions', 'answers.question', 'patient', 'consultation', 'visit', 'score']); + }); + } + + /** + * @param array $answers keyed by question code + */ + public function saveAnswers( + Assessment $assessment, + string $ownerRef, + array $answers, + ?Member $member = null, + ?string $actorRef = null, + ?string $notes = null, + ): Assessment { + $this->assertOwner($assessment, $ownerRef); + $this->assertDraft($assessment); + $assessment->loadMissing('template.questions'); + $this->assertCaptureAllowed($member, $assessment->template); + + DB::transaction(function () use ($assessment, $ownerRef, $answers, $notes) { + foreach ($answers as $code => $raw) { + $question = $assessment->template->questions->firstWhere('code', $code); + if (! $question) { + throw ValidationException::withMessages([ + "answers.{$code}" => "Unknown question code [{$code}].", + ]); + } + + if ($question->answer_type === AssessmentQuestion::TYPE_CALCULATED) { + continue; + } + + $normalized = $this->normalizeValue($question, $raw); + + AssessmentAnswer::updateOrCreate( + [ + 'assessment_id' => $assessment->id, + 'question_id' => $question->id, + ], + array_merge( + ['owner_ref' => $ownerRef], + $normalized, + ), + ); + } + + if ($notes !== null) { + $assessment->update(['notes' => $notes]); + } + }); + + AuditLogger::record( + $ownerRef, + 'assessment.updated', + $assessment->organization_id, + $actorRef, + Assessment::class, + $assessment->id, + ['template_code' => $assessment->template->code, 'answer_keys' => array_keys($answers)], + ); + + return $assessment->fresh(['template.questions', 'answers.question', 'patient', 'consultation', 'visit']); + } + + public function complete( + Assessment $assessment, + string $ownerRef, + ?Member $member = null, + ?string $actorRef = null, + ): Assessment { + $this->assertOwner($assessment, $ownerRef); + $this->assertDraft($assessment); + $assessment->loadMissing(['template.questions', 'answers']); + $this->assertCaptureAllowed($member, $assessment->template); + + $this->validateRequiredAnswers($assessment); + + return DB::transaction(function () use ($assessment, $ownerRef, $actorRef) { + // Score first so complete fails cleanly without marking completed if scoring 422s. + $scoreMeta = []; + if ($this->scoring->requiresScore($assessment)) { + $score = $this->scoring->materialize($assessment); + $scoreMeta = [ + 'total_score' => $score->total_score, + 'max_score' => $score->max_score, + ]; + } + + $assessment->update([ + 'status' => Assessment::STATUS_COMPLETED, + 'completed_at' => now(), + 'completed_by' => $actorRef, + 'assessed_at' => $assessment->assessed_at ?? now(), + ]); + + AuditLogger::record( + $ownerRef, + 'assessment.completed', + $assessment->organization_id, + $actorRef, + Assessment::class, + $assessment->id, + array_merge(['template_code' => $assessment->template->code], $scoreMeta), + ); + + return $assessment->fresh(['template.questions', 'answers.question', 'patient', 'consultation', 'visit', 'score']); + }); + } + + public function cancel( + Assessment $assessment, + string $ownerRef, + ?Member $member = null, + ?string $actorRef = null, + ): Assessment { + $this->assertOwner($assessment, $ownerRef); + $this->assertDraft($assessment); + $assessment->loadMissing('template'); + $this->assertCaptureAllowed($member, $assessment->template); + + $assessment->update([ + 'status' => Assessment::STATUS_CANCELLED, + ]); + + AuditLogger::record( + $ownerRef, + 'assessment.cancelled', + $assessment->organization_id, + $actorRef, + Assessment::class, + $assessment->id, + ['template_code' => $assessment->template->code], + ); + + return $assessment->fresh(['template', 'patient']); + } + + /** + * @param array $filters + */ + public function listForPatient( + Patient $patient, + string $ownerRef, + array $filters = [], + ?int $branchScope = null, + ): LengthAwarePaginator { + $query = Assessment::query() + ->owned($ownerRef) + ->where('patient_id', $patient->id) + ->with(['template', 'consultation', 'visit', 'practitioner']) + ->orderByDesc('assessed_at') + ->orderByDesc('created_at'); + + if ($branchScope !== null) { + $query->where('branch_id', $branchScope); + } + + if (! empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (! empty($filters['template_code'])) { + $query->whereHas('template', fn ($q) => $q->where('code', $filters['template_code'])); + } + + if (! empty($filters['category'])) { + $query->whereHas('template', fn ($q) => $q->where('category', $filters['category'])); + } + + if (! empty($filters['from'])) { + $query->whereDate('assessed_at', '>=', $filters['from']); + } + + if (! empty($filters['to'])) { + $query->whereDate('assessed_at', '<=', $filters['to']); + } + + $perPage = min(max((int) ($filters['per_page'] ?? 20), 1), 100); + + return $query->paginate($perPage); + } + + public function assertCaptureAllowed(?Member $member, AssessmentTemplate $template): void + { + if ($member === null) { + throw new HttpException(403, 'Not authorized to capture assessments.'); + } + + if (in_array($member->role, ['hospital_admin', 'super_admin'], true)) { + return; + } + + $roles = $template->meta['capture_roles'] + ?? $this->defaultCaptureRoles($template->category); + + if (! in_array($member->role, $roles, true)) { + throw new HttpException(403, 'Your role cannot capture this assessment template.'); + } + } + + /** + * @return list + */ + public function defaultCaptureRoles(string $category): array + { + return match ($category) { + AssessmentTemplate::CATEGORY_DISEASE => ['doctor'], + default => ['doctor', 'nurse'], + }; + } + + /** + * Normalize a raw answer into typed columns (exactly one authoritative column set). + * + * @return array{ + * value_text: ?string, + * value_number: ?string|null, + * value_boolean: ?bool, + * value_date: ?string, + * value_json: mixed + * } + */ + public function normalizeValue(AssessmentQuestion $question, mixed $raw): array + { + $empty = [ + 'value_text' => null, + 'value_number' => null, + 'value_boolean' => null, + 'value_date' => null, + 'value_json' => null, + ]; + + if ($raw === null || $raw === '') { + return $empty; + } + + $type = $question->answer_type; + $options = $question->options ?? []; + $validation = $question->validation ?? []; + + return match ($type) { + AssessmentQuestion::TYPE_TEXT, + AssessmentQuestion::TYPE_TEXTAREA => array_merge($empty, [ + 'value_text' => $this->validatedString($raw, $question->code, $validation), + ]), + AssessmentQuestion::TYPE_SINGLE_CHOICE => array_merge($empty, [ + 'value_text' => $this->validatedChoiceCode($raw, $question->code, $options), + ]), + AssessmentQuestion::TYPE_NUMBER, + AssessmentQuestion::TYPE_INTEGER, + AssessmentQuestion::TYPE_SCALE => array_merge($empty, [ + 'value_number' => $this->validatedNumber($raw, $question->code, $options, $validation, $type === AssessmentQuestion::TYPE_INTEGER), + ]), + AssessmentQuestion::TYPE_SCORE_ITEM => array_merge($empty, [ + 'value_number' => $this->validatedScoreItem($raw, $question->code, $options), + ]), + AssessmentQuestion::TYPE_BOOLEAN => array_merge($empty, [ + 'value_boolean' => filter_var($raw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $raw, + ]), + AssessmentQuestion::TYPE_DATE => array_merge($empty, [ + 'value_date' => $this->validatedDate($raw, $question->code, false), + ]), + AssessmentQuestion::TYPE_DATETIME => array_merge($empty, [ + 'value_date' => $this->validatedDate($raw, $question->code, true), + ]), + AssessmentQuestion::TYPE_MULTI_CHOICE => array_merge($empty, [ + 'value_json' => $this->validatedMultiChoice($raw, $question->code, $options), + ]), + AssessmentQuestion::TYPE_CALCULATED => $empty, + default => throw ValidationException::withMessages([ + "answers.{$question->code}" => "Unsupported answer type [{$type}].", + ]), + }; + } + + protected function assertOwner(Assessment $assessment, string $ownerRef): void + { + if ($assessment->owner_ref !== $ownerRef) { + throw new HttpException(404, 'Assessment not found.'); + } + } + + protected function assertDraft(Assessment $assessment): void + { + if ($assessment->status !== Assessment::STATUS_DRAFT) { + throw ValidationException::withMessages([ + 'status' => 'Only draft assessments can be modified. Completed assessments are immutable.', + ]); + } + } + + protected function validateRequiredAnswers(Assessment $assessment): void + { + $answersByQuestion = $assessment->answers->keyBy('question_id'); + $errors = []; + + foreach ($assessment->template->questions as $question) { + if ($question->answer_type === AssessmentQuestion::TYPE_CALCULATED) { + continue; + } + + if (! $question->is_required) { + continue; + } + + $answer = $answersByQuestion->get($question->id); + if (! $answer || $this->isEmptyAuthoritative($answer, $question->answer_type)) { + $errors["answers.{$question->code}"] = "{$question->label} is required."; + } + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } + + protected function isEmptyAuthoritative(AssessmentAnswer $answer, string $type): bool + { + $value = $answer->authoritativeValue($type); + + if ($value === null || $value === '') { + return true; + } + + if (is_array($value) && $value === []) { + return true; + } + + return false; + } + + protected function validatedString(mixed $raw, string $code, array $validation): string + { + $value = is_scalar($raw) ? (string) $raw : ''; + $max = (int) ($validation['max'] ?? 10000); + if (mb_strlen($value) > $max) { + throw ValidationException::withMessages([ + "answers.{$code}" => "Must be at most {$max} characters.", + ]); + } + + return $value; + } + + protected function validatedChoiceCode(mixed $raw, string $code, array $options): string + { + $value = is_scalar($raw) ? (string) $raw : ''; + $choices = collect($options['choices'] ?? [])->pluck('code')->map(fn ($c) => (string) $c)->all(); + if ($choices !== [] && ! in_array($value, $choices, true)) { + throw ValidationException::withMessages([ + "answers.{$code}" => 'Invalid choice.', + ]); + } + + return $value; + } + + protected function validatedNumber(mixed $raw, string $code, array $options, array $validation, bool $integerOnly): string + { + if (! is_numeric($raw)) { + throw ValidationException::withMessages([ + "answers.{$code}" => 'Must be a number.', + ]); + } + + if ($integerOnly && (float) $raw != (int) $raw) { + throw ValidationException::withMessages([ + "answers.{$code}" => 'Must be an integer.', + ]); + } + + $number = (float) $raw; + $min = $validation['min'] ?? $options['min'] ?? null; + $max = $validation['max'] ?? $options['max'] ?? null; + + if ($min !== null && $number < (float) $min) { + throw ValidationException::withMessages([ + "answers.{$code}" => "Must be at least {$min}.", + ]); + } + if ($max !== null && $number > (float) $max) { + throw ValidationException::withMessages([ + "answers.{$code}" => "Must be at most {$max}.", + ]); + } + + return (string) $number; + } + + protected function validatedScoreItem(mixed $raw, string $code, array $options): string + { + $choices = $options['choices'] ?? []; + if (is_array($choices) && $choices !== []) { + // Accept choice code or numeric score. + if (is_string($raw) || is_int($raw)) { + $asString = (string) $raw; + foreach ($choices as $choice) { + if ((string) ($choice['code'] ?? '') === $asString) { + if (! array_key_exists('score', $choice)) { + throw ValidationException::withMessages([ + "answers.{$code}" => 'Choice has no score.', + ]); + } + + return (string) $choice['score']; + } + } + } + } + + return $this->validatedNumber($raw, $code, $options, [], false); + } + + protected function validatedDate(mixed $raw, string $code, bool $withTime): string + { + $format = $withTime ? 'Y-m-d H:i:s' : 'Y-m-d'; + $rules = [$withTime ? 'date' : 'date_format:Y-m-d']; + $validator = Validator::make( + ['v' => $raw], + ['v' => $rules], + ); + if ($validator->fails()) { + throw ValidationException::withMessages([ + "answers.{$code}" => 'Invalid date.', + ]); + } + + try { + $dt = \Carbon\Carbon::parse($raw); + + return $withTime ? $dt->format('Y-m-d H:i:s') : $dt->format('Y-m-d').' 00:00:00'; + } catch (\Throwable) { + throw ValidationException::withMessages([ + "answers.{$code}" => 'Invalid date.', + ]); + } + } + + /** + * @return list + */ + protected function validatedMultiChoice(mixed $raw, string $code, array $options): array + { + if (! is_array($raw)) { + throw ValidationException::withMessages([ + "answers.{$code}" => 'Must be a list of choices.', + ]); + } + + $allowed = collect($options['choices'] ?? [])->pluck('code')->map(fn ($c) => (string) $c)->all(); + $values = array_values(array_map(fn ($v) => (string) $v, $raw)); + + foreach ($values as $value) { + if ($allowed !== [] && ! in_array($value, $allowed, true)) { + throw ValidationException::withMessages([ + "answers.{$code}" => "Invalid choice [{$value}].", + ]); + } + } + + return $values; + } +} diff --git a/app/Services/Care/CareFeatures.php b/app/Services/Care/CareFeatures.php new file mode 100644 index 0000000..bf05988 --- /dev/null +++ b/app/Services/Care/CareFeatures.php @@ -0,0 +1,40 @@ +settings ?? []; + $rollout = is_array($settings['rollout'] ?? null) ? $settings['rollout'] : []; + + return (bool) ($rollout[$flag] ?? false); + } + + /** + * @param array $flags + */ + public function setFlags(Organization $organization, array $flags): Organization + { + $settings = $organization->settings ?? []; + $rollout = is_array($settings['rollout'] ?? null) ? $settings['rollout'] : []; + $settings['rollout'] = array_merge($rollout, $flags); + $organization->settings = $settings; + $organization->save(); + + return $organization->fresh(); + } +} diff --git a/app/Services/Care/CarePermissions.php b/app/Services/Care/CarePermissions.php index c8f41af..51dad15 100644 --- a/app/Services/Care/CarePermissions.php +++ b/app/Services/Care/CarePermissions.php @@ -19,11 +19,14 @@ class CarePermissions 'dashboard.view', 'patients.view', 'appointments.view', 'consultations.view', 'consultations.manage', 'investigations.request', 'prescriptions.manage', 'lab.results.view', + 'assessments.view', 'assessments.capture', 'assessments.manage', + 'pathways.manage', ], 'nurse' => [ 'dashboard.view', 'patients.view', 'appointments.view', 'consultations.view', 'vitals.manage', 'queue.manage', 'service_queues.console', + 'assessments.view', 'assessments.capture', ], 'lab_technician' => [ 'dashboard.view', 'patients.view', 'lab.view', 'lab.manage', diff --git a/app/Services/Care/FhirAssessmentExporter.php b/app/Services/Care/FhirAssessmentExporter.php new file mode 100644 index 0000000..7b86549 --- /dev/null +++ b/app/Services/Care/FhirAssessmentExporter.php @@ -0,0 +1,210 @@ + + */ + public function exportBundle(Assessment $assessment): array + { + $assessment->loadMissing(['template.questions', 'answers.question', 'patient', 'score']); + + $questionnaire = $this->questionnaire($assessment); + $response = $this->questionnaireResponse($assessment, $questionnaire['url']); + + return [ + 'resourceType' => 'Bundle', + 'type' => 'collection', + 'timestamp' => now()->toIso8601String(), + 'meta' => [ + 'tag' => [[ + 'system' => 'https://care.ladill.com/fhir/tags', + 'code' => 'ladill-care-assessment-export', + ]], + ], + 'entry' => [ + ['fullUrl' => $questionnaire['url'], 'resource' => $questionnaire], + ['fullUrl' => $response['id'] ? 'urn:uuid:'.$response['id'] : 'QuestionnaireResponse/'.$assessment->uuid, 'resource' => $response], + ], + ]; + } + + /** + * @return array + */ + public function questionnaire(Assessment $assessment): array + { + $template = $assessment->template; + $url = 'https://care.ladill.com/fhir/Questionnaire/'.($template?->code ?? 'unknown').'/v'.($template?->version ?? 1); + + $items = []; + foreach ($template?->questions ?? [] as $question) { + $items[] = $this->questionItem($question); + } + + return [ + 'resourceType' => 'Questionnaire', + 'id' => ($template?->code ?? 'unknown').'-v'.($template?->version ?? 1), + 'url' => $url, + 'version' => (string) ($template?->version ?? 1), + 'name' => Str::studly($template?->code ?? 'Assessment'), + 'title' => $template?->name ?? 'Assessment', + 'status' => ($template?->is_active ?? true) ? 'active' : 'retired', + 'description' => $template?->description, + 'item' => $items, + ]; + } + + /** + * @return array + */ + public function questionnaireResponse(Assessment $assessment, ?string $questionnaireUrl = null): array + { + $assessment->loadMissing(['template.questions', 'answers.question', 'patient', 'score']); + $questionnaireUrl ??= $this->questionnaire($assessment)['url']; + + $answersByQ = $assessment->answers->keyBy('question_id'); + $items = []; + foreach ($assessment->template?->questions ?? [] as $question) { + $answer = $answersByQ->get($question->id); + if (! $answer) { + continue; + } + $value = $answer->authoritativeValue($question->answer_type); + if ($value === null || $value === '') { + continue; + } + $items[] = [ + 'linkId' => $question->code, + 'text' => $question->label, + 'answer' => $this->fhirAnswers($question, $value), + ]; + } + + $status = match ($assessment->status) { + Assessment::STATUS_COMPLETED => 'completed', + Assessment::STATUS_CANCELLED => 'entered-in-error', + default => 'in-progress', + }; + + $resource = [ + 'resourceType' => 'QuestionnaireResponse', + 'id' => $assessment->uuid, + 'questionnaire' => $questionnaireUrl, + 'status' => $status, + 'authored' => ($assessment->assessed_at ?? $assessment->completed_at ?? $assessment->created_at)?->toIso8601String(), + 'subject' => [ + 'reference' => 'Patient/'.($assessment->patient?->uuid ?? $assessment->patient_id), + 'display' => $assessment->patient?->fullName(), + ], + 'item' => $items, + ]; + + if ($assessment->score) { + $resource['extension'] = [[ + 'url' => 'https://care.ladill.com/fhir/StructureDefinition/assessment-score', + 'extension' => array_values(array_filter([ + ['url' => 'total', 'valueDecimal' => (float) $assessment->score->total_score], + $assessment->score->max_score !== null + ? ['url' => 'max', 'valueDecimal' => (float) $assessment->score->max_score] + : null, + $assessment->score->severity_label + ? ['url' => 'severity', 'valueString' => $assessment->score->severity_label] + : null, + ['url' => 'templateCode', 'valueString' => $assessment->score->template_code], + ])), + ]]; + } + + return $resource; + } + + /** + * @return array + */ + protected function questionItem(AssessmentQuestion $question): array + { + $type = match ($question->answer_type) { + AssessmentQuestion::TYPE_BOOLEAN => 'boolean', + AssessmentQuestion::TYPE_INTEGER => 'integer', + AssessmentQuestion::TYPE_NUMBER, + AssessmentQuestion::TYPE_SCALE, + AssessmentQuestion::TYPE_SCORE_ITEM, + AssessmentQuestion::TYPE_CALCULATED => 'decimal', + AssessmentQuestion::TYPE_DATE => 'date', + AssessmentQuestion::TYPE_DATETIME => 'dateTime', + AssessmentQuestion::TYPE_MULTI_CHOICE => 'choice', + AssessmentQuestion::TYPE_SINGLE_CHOICE => 'choice', + default => 'string', + }; + + $item = [ + 'linkId' => $question->code, + 'text' => $question->label, + 'type' => $type, + 'required' => (bool) $question->is_required, + ]; + + if ($question->help_text) { + $item['prefix'] = $question->help_text; + } + + $choices = $question->options['choices'] ?? null; + if (is_array($choices) && $choices !== []) { + $item['answerOption'] = array_map(function ($c) { + $code = (string) ($c['code'] ?? ''); + $label = (string) ($c['label'] ?? $code); + + return [ + 'valueCoding' => [ + 'code' => $code, + 'display' => $label, + ], + ]; + }, $choices); + } + + return $item; + } + + /** + * @return list> + */ + protected function fhirAnswers(AssessmentQuestion $question, mixed $value): array + { + return match ($question->answer_type) { + AssessmentQuestion::TYPE_BOOLEAN => [['valueBoolean' => (bool) $value]], + AssessmentQuestion::TYPE_INTEGER => [['valueInteger' => (int) $value]], + AssessmentQuestion::TYPE_NUMBER, + AssessmentQuestion::TYPE_SCALE, + AssessmentQuestion::TYPE_SCORE_ITEM, + AssessmentQuestion::TYPE_CALCULATED => [['valueDecimal' => (float) $value]], + AssessmentQuestion::TYPE_DATE => [['valueDate' => $value instanceof \Carbon\Carbon + ? $value->toDateString() + : (string) $value]], + AssessmentQuestion::TYPE_DATETIME => [['valueDateTime' => $value instanceof \Carbon\Carbon + ? $value->toIso8601String() + : (string) $value]], + AssessmentQuestion::TYPE_MULTI_CHOICE => collect(is_array($value) ? $value : [$value]) + ->map(fn ($v) => ['valueString' => (string) $v]) + ->values() + ->all(), + AssessmentQuestion::TYPE_SINGLE_CHOICE => [['valueString' => (string) $value]], + default => [['valueString' => is_scalar($value) ? (string) $value : json_encode($value)]], + }; + } +} diff --git a/app/Services/Care/OutcomeTrendService.php b/app/Services/Care/OutcomeTrendService.php new file mode 100644 index 0000000..1b59f10 --- /dev/null +++ b/app/Services/Care/OutcomeTrendService.php @@ -0,0 +1,132 @@ +, assessed_at: ?\Carbon\Carbon}> + */ + public function outcomeSeries(Patient $patient, string $ownerRef, ?int $branchScope = null, int $limit = 50): Collection + { + $template = AssessmentTemplate::currentSystemByCode(self::OUTCOME_TEMPLATE_CODE); + + $query = Assessment::query() + ->owned($ownerRef) + ->where('patient_id', $patient->id) + ->where('status', Assessment::STATUS_COMPLETED) + ->with(['answers.question', 'template']) + ->orderByDesc('assessed_at') + ->orderByDesc('completed_at') + ->limit($limit); + + if ($template) { + $query->where('template_id', $template->id); + } else { + $query->whereHas('template', fn ($q) => $q->where('code', self::OUTCOME_TEMPLATE_CODE)); + } + + if ($branchScope !== null) { + $query->where('branch_id', $branchScope); + } + + return $query->get()->map(function (Assessment $assessment) { + $answers = []; + foreach ($assessment->answers as $answer) { + if ($answer->question) { + $answers[$answer->question->code] = $answer->authoritativeValue($answer->question->answer_type); + } + } + + return [ + 'assessment' => $assessment, + 'answers' => $answers, + 'assessed_at' => $assessment->assessed_at ?? $assessment->completed_at, + ]; + }); + } + + /** + * Recent instrument scores for chartable disease templates. + * + * @return Collection + */ + public function instrumentScores( + Patient $patient, + string $ownerRef, + ?int $branchScope = null, + ?string $templateCode = null, + int $limit = 50, + ): Collection { + $query = AssessmentScore::query() + ->owned($ownerRef) + ->whereHas('assessment', function ($q) use ($patient, $branchScope) { + $q->where('patient_id', $patient->id) + ->where('status', Assessment::STATUS_COMPLETED) + ->when($branchScope !== null, fn ($qq) => $qq->where('branch_id', $branchScope)); + }) + ->with(['assessment.template']) + ->orderByDesc('computed_at') + ->limit($limit); + + if ($templateCode) { + $query->where('template_code', $templateCode); + } + + return $query->get(); + } + + /** + * Latest vitals from consultations (typed path — not outcome form). + * + * @return Collection + */ + public function recentVitals(Patient $patient, string $ownerRef, int $limit = 10): Collection + { + return VitalSign::query() + ->where('owner_ref', $ownerRef) + ->whereHas('consultation', fn ($q) => $q->where('patient_id', $patient->id)) + ->orderByDesc('recorded_at') + ->limit($limit) + ->get(); + } + + /** + * Sparkline-friendly series for a single outcome numeric code (oldest → newest). + * + * @return list + */ + public function numericOutcomeSeries(Patient $patient, string $ownerRef, string $questionCode, ?int $branchScope = null): array + { + $rows = $this->outcomeSeries($patient, $ownerRef, $branchScope)->reverse()->values(); + + $out = []; + foreach ($rows as $row) { + $val = $row['answers'][$questionCode] ?? null; + if ($val === null || $val === '') { + continue; + } + $out[] = [ + 'date' => $row['assessed_at']?->format('Y-m-d') ?? '', + 'value' => is_numeric($val) ? $val + 0 : null, + ]; + } + + return $out; + } +} diff --git a/app/Services/Care/PathwayMatcher.php b/app/Services/Care/PathwayMatcher.php new file mode 100644 index 0000000..2b8049c --- /dev/null +++ b/app/Services/Care/PathwayMatcher.php @@ -0,0 +1,135 @@ + $diagnoses + * @return Collection + */ + public function match(iterable $diagnoses): Collection + { + $dxList = collect($diagnoses)->map(function ($d) { + if ($d instanceof Diagnosis) { + return [ + 'code' => $d->code, + 'description' => $d->description, + ]; + } + + return [ + 'code' => $d['code'] ?? null, + 'description' => $d['description'] ?? null, + ]; + })->filter(fn ($d) => filled($d['code']) || filled($d['description'])); + + if ($dxList->isEmpty()) { + return collect(); + } + + $pathways = ClinicalPathway::query() + ->active() + ->orderBy('sort_order') + ->get(); + + $matches = []; + + foreach ($pathways as $pathway) { + $rules = $pathway->match_rules ?? []; + $bestRank = null; + $bestReason = null; + + foreach ($dxList as $dx) { + $result = $this->matchDiagnosis($dx, $rules); + if ($result === null) { + continue; + } + if ($bestRank === null || $result['rank'] > $bestRank) { + $bestRank = $result['rank']; + $bestReason = $result['reason']; + } + } + + if ($bestRank !== null) { + $matches[] = [ + 'pathway' => $pathway, + 'pathway_code' => $pathway->code, + 'pathway_name' => $pathway->name, + 'rank' => $bestRank, + 'match_reason' => $bestReason, + ]; + } + } + + return collect($matches) + ->sort(function ($a, $b) { + if ($a['rank'] !== $b['rank']) { + return $b['rank'] <=> $a['rank']; + } + + return $a['pathway']->sort_order <=> $b['pathway']->sort_order; + }) + ->values(); + } + + /** + * @param array{code?: ?string, description?: ?string} $dx + * @param array $rules + * @return array{rank: int, reason: string}|null + */ + protected function matchDiagnosis(array $dx, array $rules): ?array + { + $desc = $this->normalize((string) ($dx['description'] ?? '')); + $codeStripped = $this->stripCode((string) ($dx['code'] ?? '')); + + foreach ($rules['exclude_keywords'] ?? [] as $exclude) { + $ex = $this->normalize((string) $exclude); + if ($ex !== '' && $desc !== '' && str_contains($desc, $ex)) { + return null; + } + } + + foreach ($rules['icd_prefixes'] ?? [] as $prefix) { + $p = $this->stripCode((string) $prefix); + if ($p !== '' && $codeStripped !== '' && str_starts_with($codeStripped, $p)) { + return ['rank' => 100, 'reason' => 'icd_prefix:'.$prefix]; + } + } + + foreach ($rules['keywords'] ?? [] as $keyword) { + $kw = $this->normalize((string) $keyword); + if ($kw !== '' && $desc !== '' && str_contains($desc, $kw)) { + return ['rank' => 50, 'reason' => 'keyword:'.$keyword]; + } + } + + return null; + } + + protected function normalize(string $s): string + { + $s = mb_strtolower(trim($s)); + $s = preg_replace('/\s+/u', ' ', $s) ?? $s; + + return $s; + } + + protected function stripCode(string $code): string + { + return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $code) ?? ''); + } +} diff --git a/app/Services/Care/PathwayService.php b/app/Services/Care/PathwayService.php new file mode 100644 index 0000000..bfb6f3f --- /dev/null +++ b/app/Services/Care/PathwayService.php @@ -0,0 +1,199 @@ + $diagnoses + * @return Collection + */ + public function suggest(Patient $patient, iterable $diagnoses): Collection + { + $activeIds = $this->activeFor($patient)->pluck('pathway_id')->all(); + + return $this->matcher->match($diagnoses)->map(function (array $row) use ($activeIds) { + $row['already_active'] = in_array($row['pathway']->id, $activeIds, true); + + return $row; + }); + } + + /** + * @param array{ + * consultation?: ?Consultation, + * activation_diagnosis_text?: ?string, + * actor?: ?string, + * notes?: ?string, + * } $context + */ + public function activate( + Patient $patient, + ClinicalPathway $pathway, + string $ownerRef, + ?Member $member, + array $context = [], + ): PatientPathway { + if (! $this->permissions->can($member, 'pathways.manage')) { + throw new HttpException(403, 'Not authorized to manage clinical pathways.'); + } + + if (! $pathway->is_active) { + throw ValidationException::withMessages([ + 'pathway_code' => 'Pathway is inactive.', + ]); + } + + $consultation = $context['consultation'] ?? null; + $actor = $context['actor'] ?? null; + $diagnosisText = $context['activation_diagnosis_text'] + ?? $this->snapshotDiagnosisText($consultation); + + return DB::transaction(function () use ($patient, $pathway, $ownerRef, $member, $consultation, $actor, $diagnosisText, $context) { + $existingActive = PatientPathway::query() + ->where('patient_id', $patient->id) + ->where('pathway_id', $pathway->id) + ->where('status', PatientPathway::STATUS_ACTIVE) + ->lockForUpdate() + ->first(); + + if ($existingActive) { + return $existingActive->load(['pathway.templates', 'assessments.template']); + } + + $patientPathway = PatientPathway::create([ + 'owner_ref' => $ownerRef, + 'organization_id' => $patient->organization_id, + 'patient_id' => $patient->id, + 'pathway_id' => $pathway->id, + 'status' => PatientPathway::STATUS_ACTIVE, + 'activated_at' => now(), + 'activated_by' => $actor, + 'activation_consultation_id' => $consultation?->id, + 'activation_diagnosis_text' => $diagnosisText ? mb_substr($diagnosisText, 0, 500) : null, + 'notes' => $context['notes'] ?? null, + ]); + + $pathway->loadMissing('templates'); + foreach ($pathway->templates->where('is_required_on_activation', true) as $binding) { + try { + $this->assessments->start( + $patient, + $binding->template_code, + $ownerRef, + $member, + [ + 'consultation' => $consultation, + 'visit' => $consultation?->visit, + 'patient_pathway' => $patientPathway, + 'actor' => $actor, + ], + ); + } catch (ValidationException $e) { + // Template may not be seeded yet (content pack PR); skip missing packs. + if (isset($e->errors()['template_code'])) { + continue; + } + throw $e; + } + } + + AuditLogger::record( + $ownerRef, + 'pathway.activated', + $patient->organization_id, + $actor, + PatientPathway::class, + $patientPathway->id, + [ + 'pathway_code' => $pathway->code, + 'activation_diagnosis_text' => $patientPathway->activation_diagnosis_text, + ], + ); + + return $patientPathway->load(['pathway.templates', 'assessments.template']); + }); + } + + public function deactivate( + PatientPathway $patientPathway, + string $ownerRef, + ?Member $member, + ?string $actorRef = null, + ): PatientPathway { + if (! $this->permissions->can($member, 'pathways.manage')) { + throw new HttpException(403, 'Not authorized to manage clinical pathways.'); + } + + if ($patientPathway->owner_ref !== $ownerRef) { + throw new HttpException(404, 'Pathway enrollment not found.'); + } + + if ($patientPathway->status !== PatientPathway::STATUS_ACTIVE) { + return $patientPathway; + } + + $patientPathway->update([ + 'status' => PatientPathway::STATUS_INACTIVE, + 'resolved_at' => now(), + ]); + + AuditLogger::record( + $ownerRef, + 'pathway.deactivated', + $patientPathway->organization_id, + $actorRef, + PatientPathway::class, + $patientPathway->id, + ['pathway_code' => $patientPathway->pathway?->code ?? $patientPathway->pathway_id], + ); + + return $patientPathway->fresh(['pathway']); + } + + /** + * @return Collection + */ + public function activeFor(Patient $patient): Collection + { + return PatientPathway::query() + ->where('patient_id', $patient->id) + ->where('status', PatientPathway::STATUS_ACTIVE) + ->with('pathway') + ->orderByDesc('activated_at') + ->get(); + } + + protected function snapshotDiagnosisText(?Consultation $consultation): ?string + { + if (! $consultation) { + return null; + } + + $consultation->loadMissing('diagnoses'); + if ($consultation->diagnoses->isEmpty()) { + return null; + } + + return $consultation->diagnoses + ->map(fn ($d) => trim(($d->code ? $d->code.' ' : '').$d->description)) + ->filter() + ->implode('; '); + } +} diff --git a/app/Services/Care/ReportService.php b/app/Services/Care/ReportService.php index a862564..20f2ca3 100644 --- a/app/Services/Care/ReportService.php +++ b/app/Services/Care/ReportService.php @@ -3,10 +3,13 @@ namespace App\Services\Care; use App\Models\Appointment; +use App\Models\Assessment; +use App\Models\AssessmentScore; use App\Models\Bill; use App\Models\Diagnosis; use App\Models\InvestigationRequest; use App\Models\Patient; +use App\Models\PatientPathway; use App\Models\Payment; use App\Models\Visit; use Illuminate\Database\Eloquent\Builder; @@ -200,4 +203,101 @@ class ReportService ->limit(20) ->get(); } + + /** + * Org-level assessment analytics (Enterprise / assessment_analytics). + * + * @return array{ + * summary: array, + * by_template: Collection, + * by_pathway: Collection, + * score_averages: Collection + * } + */ + public function assessmentsReport( + string $ownerRef, + int $organizationId, + Carbon $from, + Carbon $to, + ?int $branchId = null, + ): array { + $base = Assessment::query() + ->from('care_assessments') + ->where('care_assessments.owner_ref', $ownerRef) + ->where('care_assessments.organization_id', $organizationId) + ->when($branchId, fn ($q) => $q->where('care_assessments.branch_id', $branchId)) + ->whereBetween('care_assessments.created_at', [$from, $to]) + ->whereNull('care_assessments.deleted_at'); + + $summary = [ + 'total_started' => (clone $base)->count(), + 'completed' => (clone $base)->where('care_assessments.status', Assessment::STATUS_COMPLETED)->count(), + 'draft' => (clone $base)->where('care_assessments.status', Assessment::STATUS_DRAFT)->count(), + 'cancelled' => (clone $base)->where('care_assessments.status', Assessment::STATUS_CANCELLED)->count(), + 'patients_with_assessments' => (clone $base)->distinct('care_assessments.patient_id')->count('care_assessments.patient_id'), + 'pathways_activated' => PatientPathway::query() + ->where('care_patient_pathways.owner_ref', $ownerRef) + ->where('care_patient_pathways.organization_id', $organizationId) + ->whereBetween('care_patient_pathways.activated_at', [$from, $to]) + ->whereNull('care_patient_pathways.deleted_at') + ->count(), + ]; + + $byTemplate = (clone $base) + ->join('care_assessment_templates', 'care_assessments.template_id', '=', 'care_assessment_templates.id') + ->select( + 'care_assessment_templates.code as template_code', + 'care_assessment_templates.name as template_name', + DB::raw('count(*) as total'), + DB::raw("sum(case when care_assessments.status = 'completed' then 1 else 0 end) as completed"), + ) + ->groupBy('care_assessment_templates.code', 'care_assessment_templates.name') + ->orderByDesc('total') + ->limit(30) + ->get(); + + $byPathway = PatientPathway::query() + ->from('care_patient_pathways') + ->where('care_patient_pathways.owner_ref', $ownerRef) + ->where('care_patient_pathways.organization_id', $organizationId) + ->whereBetween('care_patient_pathways.activated_at', [$from, $to]) + ->whereNull('care_patient_pathways.deleted_at') + ->join('care_clinical_pathways', 'care_patient_pathways.pathway_id', '=', 'care_clinical_pathways.id') + ->select( + 'care_clinical_pathways.code as pathway_code', + 'care_clinical_pathways.name as pathway_name', + DB::raw('count(*) as total'), + DB::raw("sum(case when care_patient_pathways.status = 'active' then 1 else 0 end) as still_active"), + ) + ->groupBy('care_clinical_pathways.code', 'care_clinical_pathways.name') + ->orderByDesc('total') + ->limit(20) + ->get(); + + $scoreAverages = AssessmentScore::query() + ->from('care_assessment_scores') + ->where('care_assessment_scores.owner_ref', $ownerRef) + ->whereHas('assessment', function (Builder $q) use ($organizationId, $branchId, $from, $to) { + $q->where('care_assessments.organization_id', $organizationId) + ->when($branchId, fn ($qq) => $qq->where('care_assessments.branch_id', $branchId)) + ->whereBetween('care_assessments.completed_at', [$from, $to]); + }) + ->select( + 'care_assessment_scores.template_code', + DB::raw('count(*) as completions'), + DB::raw('avg(care_assessment_scores.total_score) as avg_total'), + DB::raw('avg(care_assessment_scores.max_score) as avg_max'), + ) + ->groupBy('care_assessment_scores.template_code') + ->orderByDesc('completions') + ->limit(20) + ->get(); + + return [ + 'summary' => $summary, + 'by_template' => $byTemplate, + 'by_pathway' => $byPathway, + 'score_averages' => $scoreAverages, + ]; + } } diff --git a/app/Services/Care/ScoringService.php b/app/Services/Care/ScoringService.php new file mode 100644 index 0000000..5a2871c --- /dev/null +++ b/app/Services/Care/ScoringService.php @@ -0,0 +1,235 @@ +, severity_label: ?string} + */ + public function preview(Assessment $assessment): array + { + $assessment->loadMissing(['template.questions', 'answers.question']); + + return $this->compute($assessment); + } + + public function materialize(Assessment $assessment): AssessmentScore + { + $assessment->loadMissing(['template.questions', 'answers.question']); + + try { + $result = $this->compute($assessment); + } catch (\Throwable $e) { + Log::warning('care.scoring.failure', [ + 'template_code' => $assessment->template?->code, + 'assessment_uuid' => $assessment->uuid, + 'message' => $e->getMessage(), + ]); + throw $e; + } + + return AssessmentScore::updateOrCreate( + ['assessment_id' => $assessment->id], + [ + 'owner_ref' => $assessment->owner_ref, + 'template_code' => $assessment->template->code, + 'total_score' => $result['total'], + 'max_score' => $result['max'], + 'subscores' => $result['subscores'] !== [] ? $result['subscores'] : null, + 'severity_label' => $result['severity_label'], + 'computed_at' => now(), + ], + ); + } + + /** + * Whether this template requires score materialization on complete. + */ + public function requiresScore(Assessment $assessment): bool + { + $strategy = $assessment->template?->scoring_strategy; + + return filled($strategy); + } + + /** + * @return array{total: ?float, max: ?float, subscores: array, severity_label: ?string} + */ + protected function compute(Assessment $assessment): array + { + $strategy = $assessment->template?->scoring_strategy; + + if (! filled($strategy)) { + return [ + 'total' => null, + 'max' => null, + 'subscores' => [], + 'severity_label' => null, + ]; + } + + if (str_starts_with($strategy, 'custom:')) { + return $this->customScore($assessment, substr($strategy, 7)); + } + + return match ($strategy) { + 'sum_items' => $this->sumItems($assessment), + 'single_value' => $this->singleValue($assessment), + default => throw ValidationException::withMessages([ + 'scoring' => "Unknown scoring strategy [{$strategy}].", + ]), + }; + } + + /** + * @return array{total: ?float, max: ?float, subscores: array, severity_label: ?string} + */ + protected function sumItems(Assessment $assessment): array + { + $scoreItems = $assessment->template->questions + ->where('answer_type', AssessmentQuestion::TYPE_SCORE_ITEM); + + if ($scoreItems->isEmpty()) { + throw ValidationException::withMessages([ + 'scoring' => 'sum_items strategy requires score_item questions.', + ]); + } + + $answersByQuestion = $assessment->answers->keyBy('question_id'); + $total = 0.0; + $max = 0.0; + $subscores = []; + $missing = []; + + foreach ($scoreItems as $question) { + $answer = $answersByQuestion->get($question->id); + $value = $answer?->value_number; + + if ($value === null || $value === '') { + $missing[] = $question->code; + continue; + } + + $num = (float) $value; + $total += $num; + $itemMax = $this->itemMaxScore($question); + $max += $itemMax; + + $key = $question->score_key ?: $question->code; + $subscores[$key] = ($subscores[$key] ?? 0) + $num; + } + + if ($missing !== []) { + throw ValidationException::withMessages( + collect($missing)->mapWithKeys( + fn ($code) => ["answers.{$code}" => 'Score item is required for scoring.'] + )->all() + ); + } + + return [ + 'total' => $total, + 'max' => $max > 0 ? $max : null, + 'subscores' => $subscores, + 'severity_label' => null, + ]; + } + + /** + * @return array{total: ?float, max: ?float, subscores: array, severity_label: ?string} + */ + protected function singleValue(Assessment $assessment): array + { + $scored = $assessment->template->questions->filter( + fn (AssessmentQuestion $q) => in_array($q->answer_type, [ + AssessmentQuestion::TYPE_SCORE_ITEM, + AssessmentQuestion::TYPE_SCALE, + AssessmentQuestion::TYPE_NUMBER, + ], true) + ); + + if ($scored->isEmpty()) { + throw ValidationException::withMessages([ + 'scoring' => 'single_value strategy requires a scored question.', + ]); + } + + $question = $scored->sortBy('sort_order')->first(); + $answer = $assessment->answers->firstWhere('question_id', $question->id); + if ($answer?->value_number === null || $answer->value_number === '') { + throw ValidationException::withMessages([ + "answers.{$question->code}" => 'Score is required for scoring.', + ]); + } + + $total = (float) $answer->value_number; + $max = $this->itemMaxScore($question); + + return [ + 'total' => $total, + 'max' => $max > 0 ? $max : null, + 'subscores' => [$question->score_key ?: $question->code => $total], + 'severity_label' => null, + ]; + } + + /** + * @return array{total: ?float, max: ?float, subscores: array, severity_label: ?string} + */ + protected function customScore(Assessment $assessment, string $handlerName): array + { + if (! preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $handlerName)) { + throw new InvalidArgumentException('Invalid custom scoring handler name.'); + } + + $class = 'App\\Services\\Care\\Scoring\\'.$handlerName; + if (! class_exists($class)) { + throw ValidationException::withMessages([ + 'scoring' => "Scoring handler [{$handlerName}] is not registered.", + ]); + } + + $handler = app($class); + if (! $handler instanceof ScoresAssessment) { + throw new InvalidArgumentException("Handler [{$handlerName}] must implement ScoresAssessment."); + } + + $result = $handler->score($assessment); + + return [ + 'total' => isset($result['total']) ? (float) $result['total'] : null, + 'max' => isset($result['max']) ? (float) $result['max'] : null, + 'subscores' => is_array($result['subscores'] ?? null) ? $result['subscores'] : [], + 'severity_label' => $result['severity_label'] ?? null, + ]; + } + + protected function itemMaxScore(AssessmentQuestion $question): float + { + $options = $question->options ?? []; + if (isset($options['max']) && is_numeric($options['max'])) { + return (float) $options['max']; + } + + $choices = $options['choices'] ?? []; + $scores = []; + foreach ($choices as $choice) { + if (isset($choice['score']) && is_numeric($choice['score'])) { + $scores[] = (float) $choice['score']; + } + } + + return $scores !== [] ? max($scores) : 0.0; + } +} diff --git a/config/care.php b/config/care.php index 3c8e3ce..7d66f82 100644 --- a/config/care.php +++ b/config/care.php @@ -84,6 +84,46 @@ return [ 'drug.created' => 'Drug added', 'drug.updated' => 'Drug updated', 'drug.batch_received' => 'Drug stock received', + 'assessment.started' => 'Assessment started', + 'assessment.updated' => 'Assessment updated', + 'assessment.completed' => 'Assessment completed', + 'assessment.cancelled' => 'Assessment cancelled', + 'pathway.activated' => 'Clinical pathway activated', + 'pathway.deactivated' => 'Clinical pathway deactivated', + ], + + 'assessment_statuses' => [ + 'draft' => 'Draft', + 'completed' => 'Completed', + 'cancelled' => 'Cancelled', + ], + + 'assessment_template_categories' => [ + 'universal' => 'Universal intake', + 'disease' => 'Disease-specific', + 'outcome' => 'Outcome measures', + 'screening' => 'Screening', + ], + + 'assessment_answer_types' => [ + 'text' => 'Text', + 'textarea' => 'Long text', + 'number' => 'Number', + 'integer' => 'Integer', + 'boolean' => 'Yes / No', + 'date' => 'Date', + 'datetime' => 'Date & time', + 'single_choice' => 'Single choice', + 'multi_choice' => 'Multiple choice', + 'scale' => 'Scale', + 'score_item' => 'Score item', + 'calculated' => 'Calculated', + ], + + 'patient_pathway_statuses' => [ + 'active' => 'Active', + 'resolved' => 'Resolved', + 'inactive' => 'Inactive', ], 'appointment_statuses' => [ @@ -218,6 +258,8 @@ return [ 'laboratory' => 'Laboratory', 'finance' => 'Finance', 'clinical' => 'Clinical trends', + // KD-18: org-level multi-patient assessment analytics (Enterprise assessment_analytics) + 'assessments' => 'Assessment analytics', ], 'plans' => [ @@ -254,6 +296,8 @@ return [ 'pharmacy', 'billing', 'queue_integration', + // KD-18: org-level multi-patient assessment/outcome analytics + 'assessment_analytics', ], ], ], diff --git a/database/data/assessments/asthma_core.v1.json b/database/data/assessments/asthma_core.v1.json new file mode 100644 index 0000000..21794bc --- /dev/null +++ b/database/data/assessments/asthma_core.v1.json @@ -0,0 +1,146 @@ +{ + "template": { + "code": "asthma_core", + "name": "Asthma assessment", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": null, + "description": "Control, exacerbations, inhaler technique, peak flow.", + "meta": { + "capture_roles": [ + "doctor", + "nurse" + ], + "specialty": "respiratory", + "estimated_minutes": 8, + "pathway_codes": [ + "asthma" + ], + "attribution": "Ladill Care Asthma assessment content pack (platform-authored clinical checklist)." + } + }, + "questions": [ + { + "code": "control", + "section": "Control", + "label": "Asthma control", + "answer_type": "single_choice", + "is_required": true, + "sort_order": 10, + "options": { + "choices": [ + { + "code": "well", + "label": "Well controlled" + }, + { + "code": "partly", + "label": "Partly controlled" + }, + { + "code": "uncontrolled", + "label": "Uncontrolled" + } + ] + } + }, + { + "code": "exacerbations_12m", + "section": "Control", + "label": "Exacerbations in last 12 months", + "answer_type": "number", + "is_required": false, + "sort_order": 20, + "options": { + "min": 0, + "max": 50 + }, + "validation": { + "min": 0, + "max": 50 + } + }, + { + "code": "peak_flow", + "section": "Lung function", + "label": "Peak expiratory flow (L/min)", + "answer_type": "number", + "is_required": false, + "sort_order": 30, + "options": { + "min": 50, + "max": 800, + "unit": "L/min" + }, + "validation": { + "min": 50, + "max": 800 + } + }, + { + "code": "inhaler_technique", + "section": "Treatment", + "label": "Inhaler technique", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 40, + "options": { + "choices": [ + { + "code": "good", + "label": "Good" + }, + { + "code": "fair", + "label": "Fair" + }, + { + "code": "poor", + "label": "Poor" + }, + { + "code": "not_assessed", + "label": "Not assessed" + } + ] + } + }, + { + "code": "night_symptoms", + "section": "Symptoms", + "label": "Night symptoms", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 50, + "options": { + "choices": [ + { + "code": "none", + "label": "None" + }, + { + "code": "occasional", + "label": "Occasional" + }, + { + "code": "frequent", + "label": "Frequent" + } + ] + } + }, + { + "code": "notes", + "section": "Notes", + "label": "Notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 100, + "validation": { + "max": 5000 + } + } + ] +} diff --git a/database/data/assessments/barthel.v1.json b/database/data/assessments/barthel.v1.json new file mode 100644 index 0000000..cbd9c78 --- /dev/null +++ b/database/data/assessments/barthel.v1.json @@ -0,0 +1,192 @@ +{ + "template": { + "code": "barthel", + "name": "Barthel Index", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": "sum_items", + "description": "Barthel Index of Activities of Daily Living (0–100). Optional on stroke pathway activation.", + "meta": { + "capture_roles": ["doctor", "nurse"], + "specialty": "neurology", + "estimated_minutes": 5, + "pathway_codes": ["stroke"], + "max_total": 100, + "attribution": "Barthel Index — standard ADL scale for clinical use. Verify licensing for commercial redistribution." + } + }, + "questions": [ + { + "code": "feeding", + "section": "ADL", + "label": "Feeding", + "answer_type": "score_item", + "is_required": true, + "sort_order": 10, + "score_key": "adl", + "options": { + "max": 10, + "choices": [ + { "code": "0", "label": "Unable", "score": 0 }, + { "code": "5", "label": "Needs help cutting, spreading butter, etc., or requires modified diet", "score": 5 }, + { "code": "10", "label": "Independent", "score": 10 } + ] + } + }, + { + "code": "bathing", + "section": "ADL", + "label": "Bathing", + "answer_type": "score_item", + "is_required": true, + "sort_order": 20, + "score_key": "adl", + "options": { + "max": 5, + "choices": [ + { "code": "0", "label": "Dependent", "score": 0 }, + { "code": "5", "label": "Independent (or in shower)", "score": 5 } + ] + } + }, + { + "code": "grooming", + "section": "ADL", + "label": "Grooming", + "answer_type": "score_item", + "is_required": true, + "sort_order": 30, + "score_key": "adl", + "options": { + "max": 5, + "choices": [ + { "code": "0", "label": "Needs help with personal care", "score": 0 }, + { "code": "5", "label": "Independent face/hair/teeth/shaving", "score": 5 } + ] + } + }, + { + "code": "dressing", + "section": "ADL", + "label": "Dressing", + "answer_type": "score_item", + "is_required": true, + "sort_order": 40, + "score_key": "adl", + "options": { + "max": 10, + "choices": [ + { "code": "0", "label": "Dependent", "score": 0 }, + { "code": "5", "label": "Needs help but can do about half unaided", "score": 5 }, + { "code": "10", "label": "Independent (including buttons, zips, laces, etc.)", "score": 10 } + ] + } + }, + { + "code": "bowels", + "section": "ADL", + "label": "Bowels", + "answer_type": "score_item", + "is_required": true, + "sort_order": 50, + "score_key": "adl", + "options": { + "max": 10, + "choices": [ + { "code": "0", "label": "Incontinent (or needs to be given enemas)", "score": 0 }, + { "code": "5", "label": "Occasional accident", "score": 5 }, + { "code": "10", "label": "Continent", "score": 10 } + ] + } + }, + { + "code": "bladder", + "section": "ADL", + "label": "Bladder", + "answer_type": "score_item", + "is_required": true, + "sort_order": 60, + "score_key": "adl", + "options": { + "max": 10, + "choices": [ + { "code": "0", "label": "Incontinent, or catheterized and unable to manage alone", "score": 0 }, + { "code": "5", "label": "Occasional accident", "score": 5 }, + { "code": "10", "label": "Continent", "score": 10 } + ] + } + }, + { + "code": "toilet", + "section": "ADL", + "label": "Toilet use", + "answer_type": "score_item", + "is_required": true, + "sort_order": 70, + "score_key": "adl", + "options": { + "max": 10, + "choices": [ + { "code": "0", "label": "Dependent", "score": 0 }, + { "code": "5", "label": "Needs some help, but can do something alone", "score": 5 }, + { "code": "10", "label": "Independent (on and off, dressing, wiping)", "score": 10 } + ] + } + }, + { + "code": "transfers", + "section": "ADL", + "label": "Transfers (bed to chair and back)", + "answer_type": "score_item", + "is_required": true, + "sort_order": 80, + "score_key": "adl", + "options": { + "max": 15, + "choices": [ + { "code": "0", "label": "Unable, no sitting balance", "score": 0 }, + { "code": "5", "label": "Major help (one or two people, physical), can sit", "score": 5 }, + { "code": "10", "label": "Minor help (verbal or physical)", "score": 10 }, + { "code": "15", "label": "Independent", "score": 15 } + ] + } + }, + { + "code": "mobility", + "section": "ADL", + "label": "Mobility (on level surfaces)", + "answer_type": "score_item", + "is_required": true, + "sort_order": 90, + "score_key": "adl", + "options": { + "max": 15, + "choices": [ + { "code": "0", "label": "Immobile or <50 yards", "score": 0 }, + { "code": "5", "label": "Wheelchair independent, including corners, >50 yards", "score": 5 }, + { "code": "10", "label": "Walks with help of one person (verbal or physical) >50 yards", "score": 10 }, + { "code": "15", "label": "Independent (but may use any aid; for example, stick) >50 yards", "score": 15 } + ] + } + }, + { + "code": "stairs", + "section": "ADL", + "label": "Stairs", + "answer_type": "score_item", + "is_required": true, + "sort_order": 100, + "score_key": "adl", + "options": { + "max": 10, + "choices": [ + { "code": "0", "label": "Unable", "score": 0 }, + { "code": "5", "label": "Needs help (verbal, physical, carrying aid)", "score": 5 }, + { "code": "10", "label": "Independent", "score": 10 } + ] + } + } + ] +} diff --git a/database/data/assessments/cancer_core.v1.json b/database/data/assessments/cancer_core.v1.json new file mode 100644 index 0000000..d92cfb4 --- /dev/null +++ b/database/data/assessments/cancer_core.v1.json @@ -0,0 +1,150 @@ +{ + "template": { + "code": "cancer_core", + "name": "Cancer care assessment", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": "single_value", + "description": "Primary site, stage summary, performance status, treatment phase.", + "meta": { + "capture_roles": [ + "doctor", + "nurse" + ], + "specialty": "oncology", + "estimated_minutes": 8, + "pathway_codes": [ + "cancer" + ], + "attribution": "Ladill Care Cancer care assessment content pack (platform-authored clinical checklist)." + } + }, + "questions": [ + { + "code": "primary_site", + "section": "Disease", + "label": "Primary site / diagnosis", + "answer_type": "text", + "is_required": true, + "sort_order": 10, + "validation": { + "max": 5000 + } + }, + { + "code": "stage_summary", + "section": "Disease", + "label": "Stage summary", + "answer_type": "single_choice", + "is_required": true, + "sort_order": 20, + "options": { + "choices": [ + { + "code": "local", + "label": "Localized" + }, + { + "code": "regional", + "label": "Regional" + }, + { + "code": "metastatic", + "label": "Metastatic" + }, + { + "code": "unknown", + "label": "Unknown" + }, + { + "code": "remission", + "label": "Remission" + } + ] + } + }, + { + "code": "ecog", + "section": "Function", + "label": "ECOG performance status", + "answer_type": "score_item", + "is_required": true, + "sort_order": 30, + "options": { + "choices": [ + { + "code": "0", + "label": "0 \u2014 Fully active", + "score": 0 + }, + { + "code": "1", + "label": "1 \u2014 Restricted strenuous", + "score": 1 + }, + { + "code": "2", + "label": "2 \u2014 Ambulatory, self-care", + "score": 2 + }, + { + "code": "3", + "label": "3 \u2014 Limited self-care", + "score": 3 + }, + { + "code": "4", + "label": "4 \u2014 Completely disabled", + "score": 4 + } + ] + }, + "score_key": "ecog" + }, + { + "code": "treatment_phase", + "section": "Treatment", + "label": "Treatment phase", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 40, + "options": { + "choices": [ + { + "code": "workup", + "label": "Work-up" + }, + { + "code": "active", + "label": "Active treatment" + }, + { + "code": "surveillance", + "label": "Surveillance" + }, + { + "code": "palliative", + "label": "Palliative" + }, + { + "code": "survivorship", + "label": "Survivorship" + } + ] + } + }, + { + "code": "notes", + "section": "Notes", + "label": "Notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 100, + "validation": { + "max": 5000 + } + } + ] +} diff --git a/database/data/assessments/ckd_core.v1.json b/database/data/assessments/ckd_core.v1.json new file mode 100644 index 0000000..3917e07 --- /dev/null +++ b/database/data/assessments/ckd_core.v1.json @@ -0,0 +1,187 @@ +{ + "template": { + "code": "ckd_core", + "name": "CKD assessment", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": null, + "description": "CKD stage, creatinine, eGFR, proteinuria, anaemia flags.", + "meta": { + "capture_roles": [ + "doctor", + "nurse" + ], + "specialty": "nephrology", + "estimated_minutes": 8, + "pathway_codes": [ + "ckd" + ], + "attribution": "Ladill Care CKD assessment content pack (platform-authored clinical checklist)." + } + }, + "questions": [ + { + "code": "ckd_stage", + "section": "Staging", + "label": "CKD stage", + "answer_type": "single_choice", + "is_required": true, + "sort_order": 10, + "options": { + "choices": [ + { + "code": "1", + "label": "Stage 1" + }, + { + "code": "2", + "label": "Stage 2" + }, + { + "code": "3a", + "label": "Stage 3a" + }, + { + "code": "3b", + "label": "Stage 3b" + }, + { + "code": "4", + "label": "Stage 4" + }, + { + "code": "5", + "label": "Stage 5" + } + ] + } + }, + { + "code": "creatinine", + "section": "Labs", + "label": "Creatinine (\u00b5mol/L)", + "answer_type": "number", + "is_required": false, + "sort_order": 20, + "options": { + "min": 20, + "max": 2000, + "unit": "\u00b5mol/L" + }, + "validation": { + "min": 20, + "max": 2000 + } + }, + { + "code": "egfr", + "section": "Labs", + "label": "eGFR (mL/min/1.73m\u00b2)", + "answer_type": "number", + "is_required": false, + "sort_order": 30, + "options": { + "min": 1, + "max": 150, + "unit": "mL/min" + }, + "validation": { + "min": 1, + "max": 150 + } + }, + { + "code": "proteinuria", + "section": "Labs", + "label": "Proteinuria / ACR", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 40, + "options": { + "choices": [ + { + "code": "none", + "label": "None/normal" + }, + { + "code": "micro", + "label": "Microalbuminuria" + }, + { + "code": "macro", + "label": "Overt proteinuria" + }, + { + "code": "unknown", + "label": "Unknown" + } + ] + } + }, + { + "code": "anaemia", + "section": "Complications", + "label": "Anaemia of CKD", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 50, + "options": { + "choices": [ + { + "code": "no", + "label": "No" + }, + { + "code": "yes", + "label": "Yes" + }, + { + "code": "unknown", + "label": "Unknown" + } + ] + } + }, + { + "code": "dialysis", + "section": "Treatment", + "label": "Dialysis status", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 60, + "options": { + "choices": [ + { + "code": "none", + "label": "None" + }, + { + "code": "hd", + "label": "Haemodialysis" + }, + { + "code": "pd", + "label": "Peritoneal dialysis" + }, + { + "code": "transplant", + "label": "Transplant" + } + ] + } + }, + { + "code": "notes", + "section": "Notes", + "label": "Notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 100, + "validation": { + "max": 5000 + } + } + ] +} diff --git a/database/data/assessments/copd_core.v1.json b/database/data/assessments/copd_core.v1.json new file mode 100644 index 0000000..64c1bce --- /dev/null +++ b/database/data/assessments/copd_core.v1.json @@ -0,0 +1,160 @@ +{ + "template": { + "code": "copd_core", + "name": "COPD assessment", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": "single_value", + "description": "CAT, mMRC, spirometry, pack-years, oxygen therapy.", + "meta": { + "capture_roles": [ + "doctor", + "nurse" + ], + "specialty": "respiratory", + "estimated_minutes": 8, + "pathway_codes": [ + "copd" + ], + "attribution": "Ladill Care COPD assessment content pack (platform-authored clinical checklist)." + } + }, + "questions": [ + { + "code": "mmrc", + "section": "Dyspnea", + "label": "mMRC dyspnea scale", + "answer_type": "score_item", + "is_required": true, + "sort_order": 10, + "options": { + "choices": [ + { + "code": "0", + "label": "0 \u2014 breathless only with strenuous exercise", + "score": 0 + }, + { + "code": "1", + "label": "1 \u2014 short of breath when hurrying", + "score": 1 + }, + { + "code": "2", + "label": "2 \u2014 walks slower than people of same age", + "score": 2 + }, + { + "code": "3", + "label": "3 \u2014 stops for breath after ~100 m", + "score": 3 + }, + { + "code": "4", + "label": "4 \u2014 too breathless to leave house", + "score": 4 + } + ] + }, + "score_key": "mmrc" + }, + { + "code": "cat_score", + "section": "Symptoms", + "label": "CAT score (0\u201340)", + "answer_type": "number", + "is_required": false, + "sort_order": 20, + "options": { + "min": 0, + "max": 40 + }, + "validation": { + "min": 0, + "max": 40 + } + }, + { + "code": "fev1_percent", + "section": "Spirometry", + "label": "FEV1 % predicted", + "answer_type": "number", + "is_required": false, + "sort_order": 30, + "options": { + "min": 10, + "max": 150, + "unit": "%" + }, + "validation": { + "min": 10, + "max": 150 + } + }, + { + "code": "pack_years", + "section": "Risk", + "label": "Smoking pack-years", + "answer_type": "number", + "is_required": false, + "sort_order": 40, + "options": { + "min": 0, + "max": 200 + }, + "validation": { + "min": 0, + "max": 200 + } + }, + { + "code": "oxygen_therapy", + "section": "Treatment", + "label": "Long-term oxygen therapy", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 50, + "options": { + "choices": [ + { + "code": "no", + "label": "No" + }, + { + "code": "yes", + "label": "Yes" + } + ] + } + }, + { + "code": "exacerbations_12m", + "section": "Control", + "label": "Exacerbations last 12 months", + "answer_type": "number", + "is_required": false, + "sort_order": 60, + "options": { + "min": 0, + "max": 30 + }, + "validation": { + "min": 0, + "max": 30 + } + }, + { + "code": "notes", + "section": "Notes", + "label": "Notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 100, + "validation": { + "max": 5000 + } + } + ] +} diff --git a/database/data/assessments/dementia_core.v1.json b/database/data/assessments/dementia_core.v1.json new file mode 100644 index 0000000..e6d047d --- /dev/null +++ b/database/data/assessments/dementia_core.v1.json @@ -0,0 +1,137 @@ +{ + "template": { + "code": "dementia_core", + "name": "Dementia assessment", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": null, + "description": "MMSE/MoCA scores, ADL, behaviour, caregiver burden.", + "meta": { + "capture_roles": [ + "doctor", + "nurse" + ], + "specialty": "neurology", + "estimated_minutes": 8, + "pathway_codes": [ + "dementia" + ], + "attribution": "Ladill Care Dementia assessment content pack (platform-authored clinical checklist)." + } + }, + "questions": [ + { + "code": "mmse", + "section": "Cognition", + "label": "MMSE score (0\u201330)", + "answer_type": "number", + "is_required": false, + "sort_order": 10, + "options": { + "min": 0, + "max": 30 + }, + "validation": { + "min": 0, + "max": 30 + } + }, + { + "code": "moca", + "section": "Cognition", + "label": "MoCA score (0\u201330)", + "answer_type": "number", + "is_required": false, + "sort_order": 20, + "options": { + "min": 0, + "max": 30 + }, + "validation": { + "min": 0, + "max": 30 + } + }, + { + "code": "adl", + "section": "Function", + "label": "ADL independence", + "answer_type": "single_choice", + "is_required": true, + "sort_order": 30, + "options": { + "choices": [ + { + "code": "independent", + "label": "Independent" + }, + { + "code": "partial", + "label": "Partial help" + }, + { + "code": "dependent", + "label": "Dependent" + } + ] + } + }, + { + "code": "behavioural_symptoms", + "section": "Behaviour", + "label": "Behavioural symptoms", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 40, + "options": { + "choices": [ + { + "code": "none", + "label": "None" + }, + { + "code": "mild", + "label": "Mild" + }, + { + "code": "moderate", + "label": "Moderate" + }, + { + "code": "severe", + "label": "Severe" + } + ] + } + }, + { + "code": "caregiver_burden", + "section": "Caregiver", + "label": "Caregiver burden (0\u201310)", + "answer_type": "scale", + "is_required": false, + "sort_order": 50, + "options": { + "min": 0, + "max": 10 + }, + "validation": { + "min": 0, + "max": 10 + } + }, + { + "code": "notes", + "section": "Notes", + "label": "Notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 100, + "validation": { + "max": 5000 + } + } + ] +} diff --git a/database/data/assessments/diabetes_core.v1.json b/database/data/assessments/diabetes_core.v1.json new file mode 100644 index 0000000..b8c03fc --- /dev/null +++ b/database/data/assessments/diabetes_core.v1.json @@ -0,0 +1,161 @@ +{ + "template": { + "code": "diabetes_core", + "name": "Diabetes assessment", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": null, + "description": "Core diabetes review: glycaemia, feet, eyes, kidney markers, lifestyle, and insulin regimen.", + "meta": { + "capture_roles": ["doctor", "nurse"], + "specialty": "endocrinology", + "estimated_minutes": 12, + "pathway_codes": ["diabetes"], + "attribution": "Ladill Care diabetes core assessment (platform-authored checklist aligned to common clinic review items)." + } + }, + "questions": [ + { + "code": "hba1c", + "section": "Glycaemia", + "label": "HbA1c (%)", + "help_text": "Optional manual entry. Prefer lab results when available.", + "answer_type": "number", + "is_required": false, + "sort_order": 10, + "options": { "min": 3, "max": 20, "unit": "%" }, + "validation": { "min": 3, "max": 20 } + }, + { + "code": "hba1c_date", + "section": "Glycaemia", + "label": "HbA1c date", + "answer_type": "date", + "is_required": false, + "sort_order": 20 + }, + { + "code": "hypoglycaemia_episodes", + "section": "Glycaemia", + "label": "Hypoglycaemia episodes (last 3 months)", + "answer_type": "integer", + "is_required": false, + "sort_order": 30, + "options": { "min": 0, "max": 100 }, + "validation": { "min": 0, "max": 100 } + }, + { + "code": "insulin_regimen", + "section": "Treatment", + "label": "Insulin regimen", + "answer_type": "textarea", + "is_required": false, + "sort_order": 40, + "validation": { "max": 2000 } + }, + { + "code": "diet_adherence", + "section": "Treatment", + "label": "Diet adherence", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 50, + "options": { + "choices": [ + { "code": "good", "label": "Good" }, + { "code": "fair", "label": "Fair" }, + { "code": "poor", "label": "Poor" }, + { "code": "unknown", "label": "Unknown" } + ] + } + }, + { + "code": "foot_assessment", + "section": "Complications", + "label": "Foot assessment", + "answer_type": "single_choice", + "is_required": true, + "sort_order": 60, + "options": { + "choices": [ + { "code": "normal", "label": "Normal" }, + { "code": "at_risk", "label": "At risk" }, + { "code": "ulcer", "label": "Ulcer present" }, + { "code": "amputation", "label": "Prior amputation" }, + { "code": "not_examined", "label": "Not examined" } + ] + } + }, + { + "code": "monofilament", + "section": "Complications", + "label": "Monofilament test", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 70, + "options": { + "choices": [ + { "code": "intact", "label": "Sensation intact" }, + { "code": "reduced", "label": "Reduced" }, + { "code": "absent", "label": "Absent" }, + { "code": "not_done", "label": "Not done" } + ] + } + }, + { + "code": "eye_examination", + "section": "Complications", + "label": "Eye examination / retinopathy status", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 80, + "options": { + "choices": [ + { "code": "normal", "label": "No retinopathy" }, + { "code": "non_proliferative", "label": "Non-proliferative" }, + { "code": "proliferative", "label": "Proliferative" }, + { "code": "maculopathy", "label": "Maculopathy" }, + { "code": "unknown", "label": "Unknown / pending" }, + { "code": "not_done", "label": "Not examined this visit" } + ] + } + }, + { + "code": "microalbuminuria", + "section": "Complications", + "label": "Microalbuminuria / ACR", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 90, + "options": { + "choices": [ + { "code": "normal", "label": "Normal" }, + { "code": "elevated", "label": "Elevated" }, + { "code": "unknown", "label": "Unknown" }, + { "code": "not_done", "label": "Not done" } + ] + } + }, + { + "code": "neuropathy_score", + "section": "Complications", + "label": "Neuropathy score (0–10 clinical impression)", + "answer_type": "scale", + "is_required": false, + "sort_order": 100, + "options": { "min": 0, "max": 10 }, + "validation": { "min": 0, "max": 10 } + }, + { + "code": "notes", + "section": "Notes", + "label": "Clinical notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 110, + "validation": { "max": 5000 } + } + ] +} diff --git a/database/data/assessments/gcs.v1.json b/database/data/assessments/gcs.v1.json new file mode 100644 index 0000000..b640ce1 --- /dev/null +++ b/database/data/assessments/gcs.v1.json @@ -0,0 +1,82 @@ +{ + "template": { + "code": "gcs", + "name": "Glasgow Coma Scale", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": "sum_items", + "description": "Glasgow Coma Scale (E + V + M). Total 3–15. Optional on stroke pathway.", + "meta": { + "capture_roles": ["doctor", "nurse"], + "specialty": "neurology", + "estimated_minutes": 2, + "pathway_codes": ["stroke"], + "max_total": 15, + "attribution": "Glasgow Coma Scale (GCS) — Teasdale & Jennett. Standard clinical instrument; confirm local documentation practice." + } + }, + "questions": [ + { + "code": "eye", + "section": "GCS", + "label": "Eye opening (E)", + "answer_type": "score_item", + "is_required": true, + "sort_order": 10, + "score_key": "eye", + "options": { + "min": 1, + "max": 4, + "choices": [ + { "code": "4", "label": "Spontaneous", "score": 4 }, + { "code": "3", "label": "To speech", "score": 3 }, + { "code": "2", "label": "To pain", "score": 2 }, + { "code": "1", "label": "None", "score": 1 } + ] + } + }, + { + "code": "verbal", + "section": "GCS", + "label": "Verbal response (V)", + "answer_type": "score_item", + "is_required": true, + "sort_order": 20, + "score_key": "verbal", + "options": { + "min": 1, + "max": 5, + "choices": [ + { "code": "5", "label": "Oriented", "score": 5 }, + { "code": "4", "label": "Confused", "score": 4 }, + { "code": "3", "label": "Inappropriate words", "score": 3 }, + { "code": "2", "label": "Incomprehensible sounds", "score": 2 }, + { "code": "1", "label": "None", "score": 1 } + ] + } + }, + { + "code": "motor", + "section": "GCS", + "label": "Motor response (M)", + "answer_type": "score_item", + "is_required": true, + "sort_order": 30, + "score_key": "motor", + "options": { + "min": 1, + "max": 6, + "choices": [ + { "code": "6", "label": "Obeys commands", "score": 6 }, + { "code": "5", "label": "Localizes pain", "score": 5 }, + { "code": "4", "label": "Withdrawal from pain", "score": 4 }, + { "code": "3", "label": "Flexion to pain (decorticate)", "score": 3 }, + { "code": "2", "label": "Extension to pain (decerebrate)", "score": 2 }, + { "code": "1", "label": "None", "score": 1 } + ] + } + } + ] +} diff --git a/database/data/assessments/heart_failure_core.v1.json b/database/data/assessments/heart_failure_core.v1.json new file mode 100644 index 0000000..95ef7cf --- /dev/null +++ b/database/data/assessments/heart_failure_core.v1.json @@ -0,0 +1,197 @@ +{ + "template": { + "code": "heart_failure_core", + "name": "Heart failure assessment", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": "single_value", + "description": "NYHA class, EF, BNP, fluid status for heart failure pathway.", + "meta": { + "capture_roles": [ + "doctor", + "nurse" + ], + "specialty": "cardiology", + "estimated_minutes": 8, + "pathway_codes": [ + "heart_failure" + ], + "attribution": "Ladill Care Heart failure assessment content pack (platform-authored clinical checklist)." + } + }, + "questions": [ + { + "code": "nyha", + "section": "Classification", + "label": "NYHA classification", + "answer_type": "score_item", + "is_required": true, + "sort_order": 10, + "options": { + "choices": [ + { + "code": "1", + "label": "I \u2014 No limitation", + "score": 1 + }, + { + "code": "2", + "label": "II \u2014 Slight limitation", + "score": 2 + }, + { + "code": "3", + "label": "III \u2014 Marked limitation", + "score": 3 + }, + { + "code": "4", + "label": "IV \u2014 Symptoms at rest", + "score": 4 + } + ] + }, + "score_key": "nyha" + }, + { + "code": "ejection_fraction", + "section": "Cardiac", + "label": "Ejection fraction (%)", + "answer_type": "number", + "is_required": false, + "sort_order": 20, + "options": { + "min": 5, + "max": 80, + "unit": "%" + }, + "validation": { + "min": 5, + "max": 80 + } + }, + { + "code": "bnp", + "section": "Labs", + "label": "BNP / NT-proBNP", + "answer_type": "number", + "is_required": false, + "sort_order": 30, + "options": { + "min": 0, + "max": 100000, + "unit": "pg/mL" + }, + "validation": { + "min": 0, + "max": 100000 + } + }, + { + "code": "edema", + "section": "Fluid", + "label": "Peripheral edema", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 40, + "options": { + "choices": [ + { + "code": "none", + "label": "None" + }, + { + "code": "mild", + "label": "Mild" + }, + { + "code": "moderate", + "label": "Moderate" + }, + { + "code": "severe", + "label": "Severe" + } + ] + } + }, + { + "code": "weight_kg", + "section": "Fluid", + "label": "Current weight (kg)", + "answer_type": "number", + "is_required": false, + "sort_order": 50, + "options": { + "min": 20, + "max": 300, + "unit": "kg" + }, + "validation": { + "min": 20, + "max": 300 + } + }, + { + "code": "weight_change", + "section": "Fluid", + "label": "Weight change since last visit", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 60, + "options": { + "choices": [ + { + "code": "down", + "label": "Decreased" + }, + { + "code": "stable", + "label": "Stable" + }, + { + "code": "up", + "label": "Increased" + } + ] + } + }, + { + "code": "exercise_tolerance", + "section": "Function", + "label": "Exercise tolerance", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 70, + "options": { + "choices": [ + { + "code": "good", + "label": "Good" + }, + { + "code": "reduced", + "label": "Reduced" + }, + { + "code": "poor", + "label": "Poor" + } + ] + } + }, + { + "code": "notes", + "section": "Notes", + "label": "Notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 100, + "validation": { + "max": 5000 + } + } + ] +} diff --git a/database/data/assessments/hypertension_core.v1.json b/database/data/assessments/hypertension_core.v1.json new file mode 100644 index 0000000..455b391 --- /dev/null +++ b/database/data/assessments/hypertension_core.v1.json @@ -0,0 +1,171 @@ +{ + "template": { + "code": "hypertension_core", + "name": "Hypertension assessment", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": null, + "description": "BP control, end-organ risk, adherence for hypertension pathway.", + "meta": { + "capture_roles": [ + "doctor", + "nurse" + ], + "specialty": "cardiology", + "estimated_minutes": 8, + "pathway_codes": [ + "hypertension" + ], + "attribution": "Ladill Care Hypertension assessment content pack (platform-authored clinical checklist)." + } + }, + "questions": [ + { + "code": "sbp", + "section": "BP", + "label": "Clinic SBP (mmHg)", + "answer_type": "number", + "is_required": true, + "sort_order": 10, + "options": { + "min": 60, + "max": 300, + "unit": "mmHg" + }, + "validation": { + "min": 60, + "max": 300 + } + }, + { + "code": "dbp", + "section": "BP", + "label": "Clinic DBP (mmHg)", + "answer_type": "number", + "is_required": true, + "sort_order": 20, + "options": { + "min": 30, + "max": 200, + "unit": "mmHg" + }, + "validation": { + "min": 30, + "max": 200 + } + }, + { + "code": "control_status", + "section": "BP", + "label": "BP control", + "answer_type": "single_choice", + "is_required": true, + "sort_order": 30, + "options": { + "choices": [ + { + "code": "controlled", + "label": "Controlled" + }, + { + "code": "uncontrolled", + "label": "Uncontrolled" + }, + { + "code": "unknown", + "label": "Unknown" + } + ] + } + }, + { + "code": "home_monitoring", + "section": "BP", + "label": "Home BP monitoring", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 40, + "options": { + "choices": [ + { + "code": "yes", + "label": "Yes" + }, + { + "code": "no", + "label": "No" + } + ] + } + }, + { + "code": "target_organ", + "section": "Risk", + "label": "Target organ damage", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 50, + "options": { + "choices": [ + { + "code": "none", + "label": "None known" + }, + { + "code": "lvh", + "label": "LVH" + }, + { + "code": "ckd", + "label": "CKD" + }, + { + "code": "retinopathy", + "label": "Retinopathy" + }, + { + "code": "multiple", + "label": "Multiple" + } + ] + } + }, + { + "code": "adherence", + "section": "Treatment", + "label": "Medication adherence", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 60, + "options": { + "choices": [ + { + "code": "good", + "label": "Good" + }, + { + "code": "fair", + "label": "Fair" + }, + { + "code": "poor", + "label": "Poor" + } + ] + } + }, + { + "code": "notes", + "section": "Notes", + "label": "Notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 100, + "validation": { + "max": 5000 + } + } + ] +} diff --git a/database/data/assessments/mrs.v1.json b/database/data/assessments/mrs.v1.json new file mode 100644 index 0000000..97db2d2 --- /dev/null +++ b/database/data/assessments/mrs.v1.json @@ -0,0 +1,45 @@ +{ + "template": { + "code": "mrs", + "name": "Modified Rankin Scale", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": "single_value", + "description": "Global disability scale after stroke (0–6). Required on stroke pathway activation.", + "meta": { + "capture_roles": ["doctor"], + "specialty": "neurology", + "estimated_minutes": 2, + "pathway_codes": ["stroke"], + "attribution": "Modified Rankin Scale (mRS) — clinical instrument for educational/clinical use. Verify commercial redistribution rights before packaging for sale." + } + }, + "questions": [ + { + "code": "mrs_score", + "section": "Disability", + "label": "Modified Rankin Scale score", + "help_text": "Select the grade that best describes the patient.", + "answer_type": "score_item", + "is_required": true, + "sort_order": 1, + "score_key": "total", + "options": { + "min": 0, + "max": 6, + "choices": [ + { "code": "0", "label": "No symptoms at all", "score": 0 }, + { "code": "1", "label": "No significant disability despite symptoms; able to carry out all usual duties and activities", "score": 1 }, + { "code": "2", "label": "Slight disability; unable to carry out all previous activities, but able to look after own affairs without assistance", "score": 2 }, + { "code": "3", "label": "Moderate disability; requiring some help, but able to walk without assistance", "score": 3 }, + { "code": "4", "label": "Moderately severe disability; unable to walk without assistance and unable to attend to own bodily needs without assistance", "score": 4 }, + { "code": "5", "label": "Severe disability; bedridden, incontinent and requiring constant nursing care and attention", "score": 5 }, + { "code": "6", "label": "Dead", "score": 6 } + ] + }, + "validation": { "min": 0, "max": 6 } + } + ] +} diff --git a/database/data/assessments/nihss.v1.json b/database/data/assessments/nihss.v1.json new file mode 100644 index 0000000..9201a32 --- /dev/null +++ b/database/data/assessments/nihss.v1.json @@ -0,0 +1,320 @@ +{ + "template": { + "code": "nihss", + "name": "NIH Stroke Scale", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": "sum_items", + "description": "National Institutes of Health Stroke Scale (NIHSS). Total 0–42. Required on stroke pathway activation.", + "meta": { + "capture_roles": ["doctor"], + "specialty": "neurology", + "estimated_minutes": 10, + "pathway_codes": ["stroke"], + "max_total": 42, + "attribution": "NIH Stroke Scale (NIHSS) — public-domain clinical instrument originating from NINDS/NIH. Confirm local training and documentation requirements for clinical use." + } + }, + "questions": [ + { + "code": "1a_loc", + "section": "1. Level of consciousness", + "label": "1a. Level of consciousness", + "help_text": "Alertness.", + "answer_type": "score_item", + "is_required": true, + "sort_order": 10, + "score_key": "loc", + "options": { + "min": 0, + "max": 3, + "choices": [ + { "code": "0", "label": "Alert; keenly responsive", "score": 0 }, + { "code": "1", "label": "Not alert; arousable by minor stimulation", "score": 1 }, + { "code": "2", "label": "Not alert; requires repeated stimulation / obtunded", "score": 2 }, + { "code": "3", "label": "Responds only with reflex motor or autonomic effects / totally unresponsive", "score": 3 } + ] + } + }, + { + "code": "1b_loc_questions", + "section": "1. Level of consciousness", + "label": "1b. LOC questions", + "help_text": "Month and age. Score only initial answer.", + "answer_type": "score_item", + "is_required": true, + "sort_order": 20, + "score_key": "loc", + "options": { + "min": 0, + "max": 2, + "choices": [ + { "code": "0", "label": "Answers both questions correctly", "score": 0 }, + { "code": "1", "label": "Answers one question correctly", "score": 1 }, + { "code": "2", "label": "Answers neither question correctly", "score": 2 } + ] + } + }, + { + "code": "1c_loc_commands", + "section": "1. Level of consciousness", + "label": "1c. LOC commands", + "help_text": "Open and close eyes; grip and release non-paretic hand.", + "answer_type": "score_item", + "is_required": true, + "sort_order": 30, + "score_key": "loc", + "options": { + "min": 0, + "max": 2, + "choices": [ + { "code": "0", "label": "Performs both tasks correctly", "score": 0 }, + { "code": "1", "label": "Performs one task correctly", "score": 1 }, + { "code": "2", "label": "Performs neither task correctly", "score": 2 } + ] + } + }, + { + "code": "2_gaze", + "section": "2. Best gaze", + "label": "2. Best gaze", + "help_text": "Horizontal eye movements only.", + "answer_type": "score_item", + "is_required": true, + "sort_order": 40, + "score_key": "gaze", + "options": { + "min": 0, + "max": 2, + "choices": [ + { "code": "0", "label": "Normal", "score": 0 }, + { "code": "1", "label": "Partial gaze palsy", "score": 1 }, + { "code": "2", "label": "Forced deviation, or total gaze paresis not overcome by oculocephalic maneuver", "score": 2 } + ] + } + }, + { + "code": "3_visual", + "section": "3. Visual", + "label": "3. Visual fields", + "answer_type": "score_item", + "is_required": true, + "sort_order": 50, + "score_key": "visual", + "options": { + "min": 0, + "max": 3, + "choices": [ + { "code": "0", "label": "No visual loss", "score": 0 }, + { "code": "1", "label": "Partial hemianopia", "score": 1 }, + { "code": "2", "label": "Complete hemianopia", "score": 2 }, + { "code": "3", "label": "Bilateral hemianopia (blind including cortical blindness)", "score": 3 } + ] + } + }, + { + "code": "4_facial", + "section": "4. Facial palsy", + "label": "4. Facial palsy", + "answer_type": "score_item", + "is_required": true, + "sort_order": 60, + "score_key": "facial", + "options": { + "min": 0, + "max": 3, + "choices": [ + { "code": "0", "label": "Normal symmetrical movements", "score": 0 }, + { "code": "1", "label": "Minor paralysis (flattened nasolabial fold, asymmetry on smiling)", "score": 1 }, + { "code": "2", "label": "Partial paralysis (total or near-total paralysis of lower face)", "score": 2 }, + { "code": "3", "label": "Complete paralysis of one or both sides (upper and lower face)", "score": 3 } + ] + } + }, + { + "code": "5a_motor_arm_left", + "section": "5. Motor arm", + "label": "5a. Motor arm — left", + "help_text": "Extend arms 90° (sitting) or 45° (supine) for 10 seconds.", + "answer_type": "score_item", + "is_required": true, + "sort_order": 70, + "score_key": "motor_arm", + "options": { + "min": 0, + "max": 4, + "choices": [ + { "code": "0", "label": "No drift; limb holds 90° (or 45°) for full 10 seconds", "score": 0 }, + { "code": "1", "label": "Drift; limb holds but drifts down before full 10 seconds", "score": 1 }, + { "code": "2", "label": "Some effort against gravity", "score": 2 }, + { "code": "3", "label": "No effort against gravity; limb falls", "score": 3 }, + { "code": "4", "label": "No movement", "score": 4 }, + { "code": "UN", "label": "Amputation or joint fusion (score as 0 for total; document)", "score": 0 } + ] + } + }, + { + "code": "5b_motor_arm_right", + "section": "5. Motor arm", + "label": "5b. Motor arm — right", + "answer_type": "score_item", + "is_required": true, + "sort_order": 80, + "score_key": "motor_arm", + "options": { + "min": 0, + "max": 4, + "choices": [ + { "code": "0", "label": "No drift; limb holds 90° (or 45°) for full 10 seconds", "score": 0 }, + { "code": "1", "label": "Drift; limb holds but drifts down before full 10 seconds", "score": 1 }, + { "code": "2", "label": "Some effort against gravity", "score": 2 }, + { "code": "3", "label": "No effort against gravity; limb falls", "score": 3 }, + { "code": "4", "label": "No movement", "score": 4 }, + { "code": "UN", "label": "Amputation or joint fusion (score as 0 for total; document)", "score": 0 } + ] + } + }, + { + "code": "6a_motor_leg_left", + "section": "6. Motor leg", + "label": "6a. Motor leg — left", + "help_text": "Hold leg at 30° for 5 seconds (always tested supine).", + "answer_type": "score_item", + "is_required": true, + "sort_order": 90, + "score_key": "motor_leg", + "options": { + "min": 0, + "max": 4, + "choices": [ + { "code": "0", "label": "No drift; leg holds 30° for full 5 seconds", "score": 0 }, + { "code": "1", "label": "Drift; leg falls by the end of 5-second period but does not hit bed", "score": 1 }, + { "code": "2", "label": "Some effort against gravity; leg falls to bed by 5 seconds", "score": 2 }, + { "code": "3", "label": "No effort against gravity; leg falls to bed immediately", "score": 3 }, + { "code": "4", "label": "No movement", "score": 4 }, + { "code": "UN", "label": "Amputation or joint fusion (score as 0 for total; document)", "score": 0 } + ] + } + }, + { + "code": "6b_motor_leg_right", + "section": "6. Motor leg", + "label": "6b. Motor leg — right", + "answer_type": "score_item", + "is_required": true, + "sort_order": 100, + "score_key": "motor_leg", + "options": { + "min": 0, + "max": 4, + "choices": [ + { "code": "0", "label": "No drift; leg holds 30° for full 5 seconds", "score": 0 }, + { "code": "1", "label": "Drift; leg falls by the end of 5-second period but does not hit bed", "score": 1 }, + { "code": "2", "label": "Some effort against gravity; leg falls to bed by 5 seconds", "score": 2 }, + { "code": "3", "label": "No effort against gravity; leg falls to bed immediately", "score": 3 }, + { "code": "4", "label": "No movement", "score": 4 }, + { "code": "UN", "label": "Amputation or joint fusion (score as 0 for total; document)", "score": 0 } + ] + } + }, + { + "code": "7_ataxia", + "section": "7. Limb ataxia", + "label": "7. Limb ataxia", + "help_text": "Finger-nose-finger and heel-shin tests on both sides.", + "answer_type": "score_item", + "is_required": true, + "sort_order": 110, + "score_key": "ataxia", + "options": { + "min": 0, + "max": 2, + "choices": [ + { "code": "0", "label": "Absent", "score": 0 }, + { "code": "1", "label": "Present in one limb", "score": 1 }, + { "code": "2", "label": "Present in two limbs", "score": 2 }, + { "code": "UN", "label": "Amputation or joint fusion (score as 0; document)", "score": 0 } + ] + } + }, + { + "code": "8_sensory", + "section": "8. Sensory", + "label": "8. Sensory", + "help_text": "Sensation or grimace to pinprick when consciousness decreased.", + "answer_type": "score_item", + "is_required": true, + "sort_order": 120, + "score_key": "sensory", + "options": { + "min": 0, + "max": 2, + "choices": [ + { "code": "0", "label": "Normal; no sensory loss", "score": 0 }, + { "code": "1", "label": "Mild-to-moderate sensory loss", "score": 1 }, + { "code": "2", "label": "Severe to total sensory loss", "score": 2 } + ] + } + }, + { + "code": "9_language", + "section": "9. Best language", + "label": "9. Best language", + "help_text": "Describe picture, name items, read sentences.", + "answer_type": "score_item", + "is_required": true, + "sort_order": 130, + "score_key": "language", + "options": { + "min": 0, + "max": 3, + "choices": [ + { "code": "0", "label": "No aphasia; normal", "score": 0 }, + { "code": "1", "label": "Mild-to-moderate aphasia", "score": 1 }, + { "code": "2", "label": "Severe aphasia", "score": 2 }, + { "code": "3", "label": "Mute, global aphasia; no usable speech or auditory comprehension", "score": 3 } + ] + } + }, + { + "code": "10_dysarthria", + "section": "10. Dysarthria", + "label": "10. Dysarthria", + "help_text": "Read or repeat words from list.", + "answer_type": "score_item", + "is_required": true, + "sort_order": 140, + "score_key": "dysarthria", + "options": { + "min": 0, + "max": 2, + "choices": [ + { "code": "0", "label": "Normal", "score": 0 }, + { "code": "1", "label": "Mild-to-moderate dysarthria", "score": 1 }, + { "code": "2", "label": "Severe dysarthria; unintelligible or mute/anarthric", "score": 2 }, + { "code": "UN", "label": "Intubated or other physical barrier (score as 0; document)", "score": 0 } + ] + } + }, + { + "code": "11_extinction", + "section": "11. Extinction and inattention", + "label": "11. Extinction and inattention (formerly neglect)", + "answer_type": "score_item", + "is_required": true, + "sort_order": 150, + "score_key": "extinction", + "options": { + "min": 0, + "max": 2, + "choices": [ + { "code": "0", "label": "No abnormality", "score": 0 }, + { "code": "1", "label": "Visual, tactile, auditory, spatial, or personal inattention; or extinction to bilateral simultaneous stimulation in one sensory modality", "score": 1 }, + { "code": "2", "label": "Profound hemi-inattention or extinction to more than one modality; does not recognize own hand or orients to only one side of space", "score": 2 } + ] + } + } + ] +} diff --git a/database/data/assessments/orthopaedics_core.v1.json b/database/data/assessments/orthopaedics_core.v1.json new file mode 100644 index 0000000..a095965 --- /dev/null +++ b/database/data/assessments/orthopaedics_core.v1.json @@ -0,0 +1,144 @@ +{ + "template": { + "code": "orthopaedics_core", + "name": "Orthopaedics assessment", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": null, + "description": "Injury/region, pain, weight-bearing, fracture status.", + "meta": { + "capture_roles": [ + "doctor", + "nurse" + ], + "specialty": "orthopaedics", + "estimated_minutes": 8, + "pathway_codes": [ + "orthopaedics" + ], + "attribution": "Ladill Care Orthopaedics assessment content pack (platform-authored clinical checklist)." + } + }, + "questions": [ + { + "code": "region", + "section": "Injury", + "label": "Anatomical region", + "answer_type": "text", + "is_required": true, + "sort_order": 10, + "validation": { + "max": 5000 + } + }, + { + "code": "weight_bearing", + "section": "Function", + "label": "Weight-bearing status", + "answer_type": "single_choice", + "is_required": true, + "sort_order": 20, + "options": { + "choices": [ + { + "code": "full", + "label": "Full" + }, + { + "code": "partial", + "label": "Partial" + }, + { + "code": "non", + "label": "Non-weight-bearing" + } + ] + } + }, + { + "code": "pain_score", + "section": "Symptoms", + "label": "Pain score (0\u201310)", + "answer_type": "scale", + "is_required": false, + "sort_order": 30, + "options": { + "min": 0, + "max": 10 + }, + "validation": { + "min": 0, + "max": 10 + } + }, + { + "code": "fracture_status", + "section": "Imaging", + "label": "Fracture status", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 40, + "options": { + "choices": [ + { + "code": "none", + "label": "No fracture" + }, + { + "code": "suspected", + "label": "Suspected" + }, + { + "code": "confirmed", + "label": "Confirmed" + }, + { + "code": "healed", + "label": "Healing/healed" + } + ] + } + }, + { + "code": "operative_plan", + "section": "Plan", + "label": "Operative plan", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 50, + "options": { + "choices": [ + { + "code": "none", + "label": "Conservative" + }, + { + "code": "planned", + "label": "Surgery planned" + }, + { + "code": "done", + "label": "Post-op" + }, + { + "code": "unknown", + "label": "Unknown" + } + ] + } + }, + { + "code": "notes", + "section": "Notes", + "label": "Notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 100, + "validation": { + "max": 5000 + } + } + ] +} diff --git a/database/data/assessments/outcome_core.v1.json b/database/data/assessments/outcome_core.v1.json new file mode 100644 index 0000000..14de957 --- /dev/null +++ b/database/data/assessments/outcome_core.v1.json @@ -0,0 +1,120 @@ +{ + "template": { + "code": "outcome_core", + "name": "Outcome measures", + "category": "outcome", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": null, + "description": "Generic longitudinal outcome tracking across conditions. Prefer typed vitals for weight/BP.", + "meta": { + "capture_roles": ["doctor", "nurse"], + "specialty": "general", + "estimated_minutes": 5, + "attribution": "Ladill Care generic outcome measures (platform-authored)." + } + }, + "questions": [ + { + "code": "quality_of_life", + "section": "Patient-reported", + "label": "Quality of life (0–10)", + "help_text": "0 = worst imaginable, 10 = best imaginable.", + "answer_type": "scale", + "is_required": false, + "sort_order": 10, + "options": { "min": 0, "max": 10 }, + "validation": { "min": 0, "max": 10 } + }, + { + "code": "pain_score", + "section": "Patient-reported", + "label": "Pain score (0–10)", + "answer_type": "scale", + "is_required": false, + "sort_order": 20, + "options": { "min": 0, "max": 10 }, + "validation": { "min": 0, "max": 10 } + }, + { + "code": "functional_independence", + "section": "Function", + "label": "Functional independence", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 30, + "options": { + "choices": [ + { "code": "independent", "label": "Independent" }, + { "code": "minimal_help", "label": "Minimal help" }, + { "code": "moderate_help", "label": "Moderate help" }, + { "code": "dependent", "label": "Dependent" } + ] + } + }, + { + "code": "medication_adherence", + "section": "Treatment", + "label": "Medication adherence", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 40, + "options": { + "choices": [ + { "code": "good", "label": "Good (≥80%)" }, + { "code": "fair", "label": "Fair" }, + { "code": "poor", "label": "Poor" }, + { "code": "unknown", "label": "Unknown" } + ] + } + }, + { + "code": "hospital_admissions_since_last", + "section": "Events", + "label": "Hospital admissions since last review", + "answer_type": "integer", + "is_required": false, + "sort_order": 50, + "options": { "min": 0, "max": 50 }, + "validation": { "min": 0, "max": 50 } + }, + { + "code": "falls_count", + "section": "Events", + "label": "Falls since last review", + "answer_type": "integer", + "is_required": false, + "sort_order": 60, + "options": { "min": 0, "max": 100 }, + "validation": { "min": 0, "max": 100 } + }, + { + "code": "readmissions_flag", + "section": "Events", + "label": "Unplanned readmission since last review?", + "answer_type": "boolean", + "is_required": false, + "sort_order": 70 + }, + { + "code": "patient_satisfaction", + "section": "Patient-reported", + "label": "Patient satisfaction (0–10)", + "answer_type": "scale", + "is_required": false, + "sort_order": 80, + "options": { "min": 0, "max": 10 }, + "validation": { "min": 0, "max": 10 } + }, + { + "code": "notes", + "section": "Notes", + "label": "Notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 90, + "validation": { "max": 3000 } + } + ] +} diff --git a/database/data/assessments/parkinsons_core.v1.json b/database/data/assessments/parkinsons_core.v1.json new file mode 100644 index 0000000..9fda3b2 --- /dev/null +++ b/database/data/assessments/parkinsons_core.v1.json @@ -0,0 +1,156 @@ +{ + "template": { + "code": "parkinsons_core", + "name": "Parkinson's assessment", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": "single_value", + "description": "Hoehn & Yahr, tremor, freezing, falls.", + "meta": { + "capture_roles": [ + "doctor" + ], + "specialty": "neurology", + "estimated_minutes": 8, + "pathway_codes": [ + "parkinsons" + ], + "attribution": "Ladill Care Parkinson's assessment content pack (platform-authored clinical checklist)." + } + }, + "questions": [ + { + "code": "hoehn_yahr", + "section": "Staging", + "label": "Hoehn & Yahr stage", + "answer_type": "score_item", + "is_required": true, + "sort_order": 10, + "options": { + "choices": [ + { + "code": "1", + "label": "1", + "score": 1 + }, + { + "code": "1.5", + "label": "1.5", + "score": 1.5 + }, + { + "code": "2", + "label": "2", + "score": 2 + }, + { + "code": "2.5", + "label": "2.5", + "score": 2.5 + }, + { + "code": "3", + "label": "3", + "score": 3 + }, + { + "code": "4", + "label": "4", + "score": 4 + }, + { + "code": "5", + "label": "5", + "score": 5 + } + ] + }, + "score_key": "hy" + }, + { + "code": "tremor_score", + "section": "Motor", + "label": "Tremor severity (0\u201310)", + "answer_type": "scale", + "is_required": false, + "sort_order": 20, + "options": { + "min": 0, + "max": 10 + }, + "validation": { + "min": 0, + "max": 10 + } + }, + { + "code": "freezing", + "section": "Motor", + "label": "Freezing episodes", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 30, + "options": { + "choices": [ + { + "code": "none", + "label": "None" + }, + { + "code": "rare", + "label": "Rare" + }, + { + "code": "frequent", + "label": "Frequent" + } + ] + } + }, + { + "code": "falls_6m", + "section": "Safety", + "label": "Falls in last 6 months", + "answer_type": "number", + "is_required": false, + "sort_order": 40, + "options": { + "min": 0, + "max": 50 + }, + "validation": { + "min": 0, + "max": 50 + } + }, + { + "code": "updrs_total", + "section": "Motor", + "label": "UPDRS total (if available)", + "answer_type": "number", + "is_required": false, + "sort_order": 50, + "options": { + "min": 0, + "max": 200 + }, + "validation": { + "min": 0, + "max": 200 + } + }, + { + "code": "notes", + "section": "Notes", + "label": "Notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 100, + "validation": { + "max": 5000 + } + } + ] +} diff --git a/database/data/assessments/pregnancy_core.v1.json b/database/data/assessments/pregnancy_core.v1.json new file mode 100644 index 0000000..28ba70f --- /dev/null +++ b/database/data/assessments/pregnancy_core.v1.json @@ -0,0 +1,172 @@ +{ + "template": { + "code": "pregnancy_core", + "name": "Pregnancy assessment", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": null, + "description": "Gestational age, parity, risk factors, BP, foetal movement.", + "meta": { + "capture_roles": [ + "doctor", + "nurse" + ], + "specialty": "obstetrics", + "estimated_minutes": 8, + "pathway_codes": [ + "pregnancy" + ], + "attribution": "Ladill Care Pregnancy assessment content pack (platform-authored clinical checklist)." + } + }, + "questions": [ + { + "code": "ga_weeks", + "section": "Pregnancy", + "label": "Gestational age (weeks)", + "answer_type": "number", + "is_required": true, + "sort_order": 10, + "options": { + "min": 0, + "max": 45, + "unit": "weeks" + }, + "validation": { + "min": 0, + "max": 45 + } + }, + { + "code": "gravida", + "section": "Obstetric history", + "label": "Gravida", + "answer_type": "number", + "is_required": false, + "sort_order": 20, + "options": { + "min": 0, + "max": 20 + }, + "validation": { + "min": 0, + "max": 20 + } + }, + { + "code": "para", + "section": "Obstetric history", + "label": "Para", + "answer_type": "number", + "is_required": false, + "sort_order": 30, + "options": { + "min": 0, + "max": 20 + }, + "validation": { + "min": 0, + "max": 20 + } + }, + { + "code": "sbp", + "section": "Vitals", + "label": "BP systolic", + "answer_type": "number", + "is_required": false, + "sort_order": 40, + "options": { + "min": 60, + "max": 250, + "unit": "mmHg" + }, + "validation": { + "min": 60, + "max": 250 + } + }, + { + "code": "dbp", + "section": "Vitals", + "label": "BP diastolic", + "answer_type": "number", + "is_required": false, + "sort_order": 50, + "options": { + "min": 30, + "max": 150, + "unit": "mmHg" + }, + "validation": { + "min": 30, + "max": 150 + } + }, + { + "code": "risk_category", + "section": "Risk", + "label": "Risk category", + "answer_type": "single_choice", + "is_required": true, + "sort_order": 60, + "options": { + "choices": [ + { + "code": "low", + "label": "Low" + }, + { + "code": "moderate", + "label": "Moderate" + }, + { + "code": "high", + "label": "High" + } + ] + } + }, + { + "code": "foetal_movement", + "section": "Foetal", + "label": "Foetal movements", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 70, + "options": { + "choices": [ + { + "code": "normal", + "label": "Normal" + }, + { + "code": "reduced", + "label": "Reduced" + }, + { + "code": "na", + "label": "N/A early" + }, + { + "code": "unknown", + "label": "Unknown" + } + ] + } + }, + { + "code": "notes", + "section": "Notes", + "label": "Notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 100, + "validation": { + "max": 5000 + } + } + ] +} diff --git a/database/data/assessments/stroke_clinical.v1.json b/database/data/assessments/stroke_clinical.v1.json new file mode 100644 index 0000000..d2bea05 --- /dev/null +++ b/database/data/assessments/stroke_clinical.v1.json @@ -0,0 +1,179 @@ +{ + "template": { + "code": "stroke_clinical", + "name": "Stroke clinical assessment", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": null, + "description": "Stroke subtype (incl. TIA), timeline, imaging, reperfusion, and focal findings. Optional on pathway activation.", + "meta": { + "capture_roles": ["doctor"], + "specialty": "neurology", + "estimated_minutes": 8, + "pathway_codes": ["stroke"], + "attribution": "Ladill Care stroke clinical form (platform-authored). Subtype field implements KD-19 (single stroke pathway for TIA and stroke)." + } + }, + "questions": [ + { + "code": "stroke_subtype", + "section": "Classification", + "label": "Stroke / TIA subtype", + "help_text": "TIA shares the stroke pathway; record subtype here (not a separate pathway).", + "answer_type": "single_choice", + "is_required": true, + "sort_order": 10, + "options": { + "choices": [ + { "code": "tia", "label": "TIA" }, + { "code": "ischaemic", "label": "Ischaemic stroke" }, + { "code": "haemorrhagic", "label": "Haemorrhagic stroke" }, + { "code": "unspecified", "label": "Unspecified / under investigation" } + ] + } + }, + { + "code": "tlkw", + "section": "Timeline", + "label": "Time last known well", + "answer_type": "datetime", + "is_required": false, + "sort_order": 20 + }, + { + "code": "symptom_onset", + "section": "Timeline", + "label": "Symptom onset (if witnessed)", + "answer_type": "datetime", + "is_required": false, + "sort_order": 30 + }, + { + "code": "ct_findings", + "section": "Imaging", + "label": "CT / imaging findings", + "answer_type": "textarea", + "is_required": false, + "sort_order": 40, + "validation": { "max": 5000 } + }, + { + "code": "thrombolysis_status", + "section": "Reperfusion", + "label": "Thrombolysis status", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 50, + "options": { + "choices": [ + { "code": "not_indicated", "label": "Not indicated" }, + { "code": "contraindicated", "label": "Contraindicated" }, + { "code": "given", "label": "Given" }, + { "code": "offered_declined", "label": "Offered / declined" }, + { "code": "pending", "label": "Pending decision" } + ] + } + }, + { + "code": "thrombolysis_time", + "section": "Reperfusion", + "label": "Thrombolysis time (if given)", + "answer_type": "datetime", + "is_required": false, + "sort_order": 60 + }, + { + "code": "mechanical_thrombectomy", + "section": "Reperfusion", + "label": "Mechanical thrombectomy", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 70, + "options": { + "choices": [ + { "code": "not_indicated", "label": "Not indicated" }, + { "code": "referred", "label": "Referred / transferred" }, + { "code": "performed", "label": "Performed" }, + { "code": "not_available", "label": "Not available" } + ] + } + }, + { + "code": "limb_strength", + "section": "Findings", + "label": "Limb strength summary", + "answer_type": "textarea", + "is_required": false, + "sort_order": 80, + "validation": { "max": 2000 } + }, + { + "code": "aphasia", + "section": "Findings", + "label": "Aphasia", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 90, + "options": { + "choices": [ + { "code": "none", "label": "None" }, + { "code": "mild", "label": "Mild" }, + { "code": "moderate", "label": "Moderate" }, + { "code": "severe", "label": "Severe" } + ] + } + }, + { + "code": "dysphagia", + "section": "Findings", + "label": "Dysphagia", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 100, + "options": { + "choices": [ + { "code": "none", "label": "None / not suspected" }, + { "code": "suspected", "label": "Suspected" }, + { "code": "confirmed", "label": "Confirmed" } + ] + } + }, + { + "code": "spasticity", + "section": "Findings", + "label": "Spasticity", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 110, + "options": { + "choices": [ + { "code": "none", "label": "None" }, + { "code": "mild", "label": "Mild" }, + { "code": "moderate", "label": "Moderate" }, + { "code": "severe", "label": "Severe" }, + { "code": "na_acute", "label": "N/A (hyperacute)" } + ] + } + }, + { + "code": "cognition", + "section": "Findings", + "label": "Cognition notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 120, + "validation": { "max": 2000 } + }, + { + "code": "clinical_notes", + "section": "Notes", + "label": "Additional clinical notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 130, + "validation": { "max": 10000 } + } + ] +} diff --git a/database/data/assessments/stroke_swallow.v1.json b/database/data/assessments/stroke_swallow.v1.json new file mode 100644 index 0000000..715cc32 --- /dev/null +++ b/database/data/assessments/stroke_swallow.v1.json @@ -0,0 +1,106 @@ +{ + "template": { + "code": "stroke_swallow", + "name": "Swallow assessment", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": null, + "description": "Bedside swallow screen after stroke. Optional on stroke pathway.", + "meta": { + "capture_roles": ["doctor", "nurse"], + "specialty": "neurology", + "estimated_minutes": 5, + "pathway_codes": ["stroke"], + "attribution": "Ladill Care stroke swallow screen (platform-authored checklist). Not a substitute for formal SALT assessment." + } + }, + "questions": [ + { + "code": "npo_status", + "section": "Screen", + "label": "Nil by mouth status", + "answer_type": "single_choice", + "is_required": true, + "sort_order": 10, + "options": { + "choices": [ + { "code": "npo", "label": "NBM / NPO" }, + { "code": "oral_allowed", "label": "Oral intake allowed" }, + { "code": "modified", "label": "Modified texture only" } + ] + } + }, + { + "code": "level_of_alertness", + "section": "Screen", + "label": "Alert enough for swallow trial?", + "answer_type": "boolean", + "is_required": true, + "sort_order": 20 + }, + { + "code": "voice_wet", + "section": "Screen", + "label": "Wet / gurgly voice after sip?", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 30, + "options": { + "choices": [ + { "code": "no", "label": "No" }, + { "code": "yes", "label": "Yes" }, + { "code": "not_tested", "label": "Not tested" } + ] + } + }, + { + "code": "cough_after_sip", + "section": "Screen", + "label": "Cough / choke on water trial?", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 40, + "options": { + "choices": [ + { "code": "no", "label": "No" }, + { "code": "yes", "label": "Yes" }, + { "code": "not_tested", "label": "Not tested" } + ] + } + }, + { + "code": "screen_result", + "section": "Result", + "label": "Screen result", + "answer_type": "single_choice", + "is_required": true, + "sort_order": 50, + "options": { + "choices": [ + { "code": "pass", "label": "Pass — oral trial appropriate" }, + { "code": "fail", "label": "Fail — keep NBM, refer SALT" }, + { "code": "deferred", "label": "Deferred — not safe to screen" } + ] + } + }, + { + "code": "salt_referral", + "section": "Result", + "label": "Speech & language therapy referral", + "answer_type": "boolean", + "is_required": false, + "sort_order": 60 + }, + { + "code": "notes", + "section": "Result", + "label": "Notes", + "answer_type": "textarea", + "is_required": false, + "sort_order": 70, + "validation": { "max": 2000 } + } + ] +} diff --git a/database/data/assessments/universal_intake.v1.json b/database/data/assessments/universal_intake.v1.json new file mode 100644 index 0000000..d1de212 --- /dev/null +++ b/database/data/assessments/universal_intake.v1.json @@ -0,0 +1,256 @@ +{ + "template": { + "code": "universal_intake", + "name": "Universal Intake", + "category": "universal", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": null, + "description": "Disease-agnostic structured intake: presenting complaint, social history, and functional status. Demographics, allergies, vitals, and labs remain on typed Care forms.", + "meta": { + "capture_roles": ["doctor", "nurse"], + "specialty": "general", + "estimated_minutes": 8, + "attribution": "Ladill Care universal clinical intake (platform-authored). Not a licensed third-party instrument." + } + }, + "questions": [ + { + "code": "chief_complaint", + "section": "Presenting complaint", + "label": "Chief complaint", + "help_text": "Primary reason for this visit in the patient's own words when possible.", + "answer_type": "textarea", + "is_required": true, + "sort_order": 10, + "options": null, + "validation": { "max": 2000 } + }, + { + "code": "hpi", + "section": "Presenting complaint", + "label": "History of presenting illness", + "help_text": "Onset, course, associated features. Free-text symptoms on the consultation remain the narrative source of truth (not auto-synced).", + "answer_type": "textarea", + "is_required": false, + "sort_order": 20, + "options": null, + "validation": { "max": 10000 } + }, + { + "code": "duration_value", + "section": "Presenting complaint", + "label": "Duration (value)", + "help_text": "Numeric duration; pair with duration unit.", + "answer_type": "number", + "is_required": false, + "sort_order": 30, + "options": { "min": 0, "max": 9999 }, + "validation": { "min": 0, "max": 9999 } + }, + { + "code": "duration_unit", + "section": "Presenting complaint", + "label": "Duration unit", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 40, + "options": { + "choices": [ + { "code": "minutes", "label": "Minutes" }, + { "code": "hours", "label": "Hours" }, + { "code": "days", "label": "Days" }, + { "code": "weeks", "label": "Weeks" }, + { "code": "months", "label": "Months" }, + { "code": "years", "label": "Years" } + ] + } + }, + { + "code": "pain_score", + "section": "Presenting complaint", + "label": "Pain score (0–10)", + "help_text": "0 = no pain, 10 = worst imaginable pain.", + "answer_type": "scale", + "is_required": false, + "sort_order": 50, + "options": { "min": 0, "max": 10 }, + "validation": { "min": 0, "max": 10 } + }, + { + "code": "current_symptoms", + "section": "Presenting complaint", + "label": "Current symptoms", + "help_text": "Structured symptom list or free description. Does not replace consultation free-text symptoms.", + "answer_type": "textarea", + "is_required": false, + "sort_order": 60, + "options": null, + "validation": { "max": 5000 } + }, + { + "code": "smoking_status", + "section": "Social history", + "label": "Smoking status", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 100, + "options": { + "choices": [ + { "code": "never", "label": "Never smoked" }, + { "code": "former", "label": "Former smoker" }, + { "code": "current", "label": "Current smoker" }, + { "code": "unknown", "label": "Unknown / not asked" } + ] + } + }, + { + "code": "smoking_pack_years", + "section": "Social history", + "label": "Smoking pack-years", + "help_text": "If former or current smoker. Packs/day × years.", + "answer_type": "number", + "is_required": false, + "sort_order": 110, + "options": { "min": 0, "max": 500, "unit": "pack-years" }, + "validation": { "min": 0, "max": 500 } + }, + { + "code": "alcohol_use", + "section": "Social history", + "label": "Alcohol use", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 120, + "options": { + "choices": [ + { "code": "none", "label": "None" }, + { "code": "occasional", "label": "Occasional" }, + { "code": "moderate", "label": "Moderate" }, + { "code": "heavy", "label": "Heavy" }, + { "code": "unknown", "label": "Unknown / not asked" } + ] + } + }, + { + "code": "occupation", + "section": "Social history", + "label": "Occupation", + "answer_type": "text", + "is_required": false, + "sort_order": 130, + "options": null, + "validation": { "max": 255 } + }, + { + "code": "mobility", + "section": "Functional status", + "label": "Mobility", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 200, + "options": { + "choices": [ + { "code": "independent", "label": "Independent" }, + { "code": "with_aid", "label": "With aid (stick/frame)" }, + { "code": "wheelchair", "label": "Wheelchair" }, + { "code": "bedbound", "label": "Bedbound" }, + { "code": "unknown", "label": "Unknown" } + ] + } + }, + { + "code": "communication", + "section": "Functional status", + "label": "Communication", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 210, + "options": { + "choices": [ + { "code": "normal", "label": "Normal" }, + { "code": "impaired", "label": "Impaired" }, + { "code": "non_verbal", "label": "Non-verbal" }, + { "code": "unknown", "label": "Unknown" } + ] + } + }, + { + "code": "vision", + "section": "Functional status", + "label": "Vision", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 220, + "options": { + "choices": [ + { "code": "normal", "label": "Normal / corrected" }, + { "code": "impaired", "label": "Impaired" }, + { "code": "blind", "label": "Blind" }, + { "code": "unknown", "label": "Unknown" } + ] + } + }, + { + "code": "hearing", + "section": "Functional status", + "label": "Hearing", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 230, + "options": { + "choices": [ + { "code": "normal", "label": "Normal" }, + { "code": "impaired", "label": "Impaired" }, + { "code": "deaf", "label": "Deaf" }, + { "code": "unknown", "label": "Unknown" } + ] + } + }, + { + "code": "feeding", + "section": "Functional status", + "label": "Feeding", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 240, + "options": { + "choices": [ + { "code": "independent", "label": "Independent" }, + { "code": "needs_help", "label": "Needs help" }, + { "code": "dependent", "label": "Dependent / tube" }, + { "code": "unknown", "label": "Unknown" } + ] + } + }, + { + "code": "continence", + "section": "Functional status", + "label": "Continence", + "answer_type": "single_choice", + "is_required": false, + "sort_order": 250, + "options": { + "choices": [ + { "code": "continent", "label": "Continent" }, + { "code": "occasional", "label": "Occasional incontinence" }, + { "code": "incontinent", "label": "Incontinent" }, + { "code": "catheter", "label": "Catheter / diversion" }, + { "code": "unknown", "label": "Unknown" } + ] + } + }, + { + "code": "baseline_labs_summary", + "section": "Baseline laboratories", + "label": "Baseline labs summary (optional)", + "help_text": "Free-text summary only. Prefer ordered investigation results in Lab for structured values (CBC, glucose, creatinine, electrolytes).", + "answer_type": "textarea", + "is_required": false, + "sort_order": 300, + "options": null, + "validation": { "max": 5000 } + } + ] +} diff --git a/database/data/pathways/asthma.json b/database/data/pathways/asthma.json new file mode 100644 index 0000000..6b9a976 --- /dev/null +++ b/database/data/pathways/asthma.json @@ -0,0 +1,32 @@ +{ + "pathway": { + "code": "asthma", + "name": "Asthma", + "description": "Asthma clinical pathway.", + "match_rules": { + "icd_prefixes": [ + "J45" + ], + "keywords": [ + "asthma", + "asthmatic" + ], + "exclude_keywords": [ + "family history" + ] + }, + "is_active": true, + "sort_order": 60, + "meta": { + "specialty": "respiratory" + } + }, + "templates": [ + { + "template_code": "asthma_core", + "is_required_on_activation": true, + "phase": "any", + "sort_order": 1 + } + ] +} diff --git a/database/data/pathways/cancer.json b/database/data/pathways/cancer.json new file mode 100644 index 0000000..60d3099 --- /dev/null +++ b/database/data/pathways/cancer.json @@ -0,0 +1,36 @@ +{ + "pathway": { + "code": "cancer", + "name": "Cancer", + "description": "Cancer clinical pathway.", + "match_rules": { + "icd_prefixes": [ + "C" + ], + "keywords": [ + "cancer", + "carcinoma", + "malignancy", + "malignant neoplasm", + "tumour", + "tumor" + ], + "exclude_keywords": [ + "family history" + ] + }, + "is_active": true, + "sort_order": 100, + "meta": { + "specialty": "oncology" + } + }, + "templates": [ + { + "template_code": "cancer_core", + "is_required_on_activation": true, + "phase": "any", + "sort_order": 1 + } + ] +} diff --git a/database/data/pathways/ckd.json b/database/data/pathways/ckd.json new file mode 100644 index 0000000..93f4d13 --- /dev/null +++ b/database/data/pathways/ckd.json @@ -0,0 +1,36 @@ +{ + "pathway": { + "code": "ckd", + "name": "CKD", + "description": "CKD clinical pathway.", + "match_rules": { + "icd_prefixes": [ + "N18", + "N19" + ], + "keywords": [ + "ckd", + "chronic kidney", + "chronic renal", + "end stage renal", + "esrd" + ], + "exclude_keywords": [ + "family history" + ] + }, + "is_active": true, + "sort_order": 40, + "meta": { + "specialty": "nephrology" + } + }, + "templates": [ + { + "template_code": "ckd_core", + "is_required_on_activation": true, + "phase": "any", + "sort_order": 1 + } + ] +} diff --git a/database/data/pathways/copd.json b/database/data/pathways/copd.json new file mode 100644 index 0000000..d4ddd5d --- /dev/null +++ b/database/data/pathways/copd.json @@ -0,0 +1,34 @@ +{ + "pathway": { + "code": "copd", + "name": "COPD", + "description": "COPD clinical pathway.", + "match_rules": { + "icd_prefixes": [ + "J44" + ], + "keywords": [ + "copd", + "chronic obstructive", + "emphysema", + "chronic bronchitis" + ], + "exclude_keywords": [ + "family history" + ] + }, + "is_active": true, + "sort_order": 70, + "meta": { + "specialty": "respiratory" + } + }, + "templates": [ + { + "template_code": "copd_core", + "is_required_on_activation": true, + "phase": "any", + "sort_order": 1 + } + ] +} diff --git a/database/data/pathways/dementia.json b/database/data/pathways/dementia.json new file mode 100644 index 0000000..edee844 --- /dev/null +++ b/database/data/pathways/dementia.json @@ -0,0 +1,38 @@ +{ + "pathway": { + "code": "dementia", + "name": "Dementia", + "description": "Dementia clinical pathway.", + "match_rules": { + "icd_prefixes": [ + "F00", + "F01", + "F02", + "F03", + "G30" + ], + "keywords": [ + "dementia", + "alzheimer", + "alzheimers", + "vascular dementia" + ], + "exclude_keywords": [ + "family history" + ] + }, + "is_active": true, + "sort_order": 80, + "meta": { + "specialty": "neurology" + } + }, + "templates": [ + { + "template_code": "dementia_core", + "is_required_on_activation": true, + "phase": "any", + "sort_order": 1 + } + ] +} diff --git a/database/data/pathways/diabetes.json b/database/data/pathways/diabetes.json new file mode 100644 index 0000000..533f7b2 --- /dev/null +++ b/database/data/pathways/diabetes.json @@ -0,0 +1,36 @@ +{ + "pathway": { + "code": "diabetes", + "name": "Diabetes", + "description": "Diabetes mellitus clinical pathway for structured review assessments.", + "match_rules": { + "icd_prefixes": ["E10", "E11", "E12", "E13", "E14"], + "keywords": [ + "diabetes", + "diabetic", + "type 1 diabetes", + "type 2 diabetes", + "dm", + "iddm", + "niddm", + "hyperglycaemia", + "hyperglycemia" + ], + "exclude_keywords": ["family history", "gestational diabetes risk"] + }, + "is_active": true, + "sort_order": 20, + "meta": { + "specialty": "endocrinology", + "color": "amber" + } + }, + "templates": [ + { + "template_code": "diabetes_core", + "is_required_on_activation": true, + "phase": "any", + "sort_order": 1 + } + ] +} diff --git a/database/data/pathways/heart_failure.json b/database/data/pathways/heart_failure.json new file mode 100644 index 0000000..eb9eb9b --- /dev/null +++ b/database/data/pathways/heart_failure.json @@ -0,0 +1,37 @@ +{ + "pathway": { + "code": "heart_failure", + "name": "Heart Failure", + "description": "Heart Failure clinical pathway.", + "match_rules": { + "icd_prefixes": [ + "I50", + "I11" + ], + "keywords": [ + "heart failure", + "chf", + "ccf", + "hfrEF", + "hfpef", + "congestive cardiac failure" + ], + "exclude_keywords": [ + "family history" + ] + }, + "is_active": true, + "sort_order": 30, + "meta": { + "specialty": "cardiology" + } + }, + "templates": [ + { + "template_code": "heart_failure_core", + "is_required_on_activation": true, + "phase": "any", + "sort_order": 1 + } + ] +} diff --git a/database/data/pathways/hypertension.json b/database/data/pathways/hypertension.json new file mode 100644 index 0000000..ea52ae2 --- /dev/null +++ b/database/data/pathways/hypertension.json @@ -0,0 +1,38 @@ +{ + "pathway": { + "code": "hypertension", + "name": "Hypertension", + "description": "Hypertension clinical pathway.", + "match_rules": { + "icd_prefixes": [ + "I10", + "I11", + "I12", + "I13", + "I15" + ], + "keywords": [ + "hypertension", + "high blood pressure", + "htn", + "hypertensive" + ], + "exclude_keywords": [ + "family history" + ] + }, + "is_active": true, + "sort_order": 50, + "meta": { + "specialty": "cardiology" + } + }, + "templates": [ + { + "template_code": "hypertension_core", + "is_required_on_activation": true, + "phase": "any", + "sort_order": 1 + } + ] +} diff --git a/database/data/pathways/orthopaedics.json b/database/data/pathways/orthopaedics.json new file mode 100644 index 0000000..2478aca --- /dev/null +++ b/database/data/pathways/orthopaedics.json @@ -0,0 +1,38 @@ +{ + "pathway": { + "code": "orthopaedics", + "name": "Orthopaedics", + "description": "Orthopaedics clinical pathway.", + "match_rules": { + "icd_prefixes": [ + "S", + "M80", + "M81", + "M84" + ], + "keywords": [ + "fracture", + "orthopaedic", + "orthopedic", + "dislocation", + "sprain" + ], + "exclude_keywords": [ + "family history" + ] + }, + "is_active": true, + "sort_order": 120, + "meta": { + "specialty": "orthopaedics" + } + }, + "templates": [ + { + "template_code": "orthopaedics_core", + "is_required_on_activation": true, + "phase": "any", + "sort_order": 1 + } + ] +} diff --git a/database/data/pathways/parkinsons.json b/database/data/pathways/parkinsons.json new file mode 100644 index 0000000..de4bde6 --- /dev/null +++ b/database/data/pathways/parkinsons.json @@ -0,0 +1,33 @@ +{ + "pathway": { + "code": "parkinsons", + "name": "Parkinson's", + "description": "Parkinson's clinical pathway.", + "match_rules": { + "icd_prefixes": [ + "G20" + ], + "keywords": [ + "parkinson", + "parkinsons", + "parkinson's disease" + ], + "exclude_keywords": [ + "family history" + ] + }, + "is_active": true, + "sort_order": 90, + "meta": { + "specialty": "neurology" + } + }, + "templates": [ + { + "template_code": "parkinsons_core", + "is_required_on_activation": true, + "phase": "any", + "sort_order": 1 + } + ] +} diff --git a/database/data/pathways/pregnancy.json b/database/data/pathways/pregnancy.json new file mode 100644 index 0000000..50c5052 --- /dev/null +++ b/database/data/pathways/pregnancy.json @@ -0,0 +1,36 @@ +{ + "pathway": { + "code": "pregnancy", + "name": "Pregnancy", + "description": "Pregnancy clinical pathway.", + "match_rules": { + "icd_prefixes": [ + "O", + "Z33", + "Z34" + ], + "keywords": [ + "pregnancy", + "pregnant", + "antenatal", + "prenatal" + ], + "exclude_keywords": [ + "family history" + ] + }, + "is_active": true, + "sort_order": 110, + "meta": { + "specialty": "obstetrics" + } + }, + "templates": [ + { + "template_code": "pregnancy_core", + "is_required_on_activation": true, + "phase": "any", + "sort_order": 1 + } + ] +} diff --git a/database/data/pathways/stroke.json b/database/data/pathways/stroke.json new file mode 100644 index 0000000..bccd69d --- /dev/null +++ b/database/data/pathways/stroke.json @@ -0,0 +1,68 @@ +{ + "pathway": { + "code": "stroke", + "name": "Stroke", + "description": "Acute and follow-up stroke clinical pathway (includes TIA as subtype on assessments).", + "match_rules": { + "icd_prefixes": ["I60", "I61", "I62", "I63", "I64", "G45"], + "keywords": [ + "stroke", + "cva", + "tia", + "transient ischaemic attack", + "transient ischemic attack", + "cerebrovascular", + "hemiplegia", + "ischaemic stroke", + "ischemic stroke", + "hemorrhagic stroke", + "haemorrhagic stroke" + ], + "exclude_keywords": ["family history"] + }, + "is_active": true, + "sort_order": 10, + "meta": { + "specialty": "neurology", + "color": "rose" + } + }, + "templates": [ + { + "template_code": "nihss", + "is_required_on_activation": true, + "phase": "acute", + "sort_order": 1 + }, + { + "template_code": "mrs", + "is_required_on_activation": true, + "phase": "acute", + "sort_order": 2 + }, + { + "template_code": "gcs", + "is_required_on_activation": false, + "phase": "acute", + "sort_order": 3 + }, + { + "template_code": "stroke_clinical", + "is_required_on_activation": false, + "phase": "acute", + "sort_order": 4 + }, + { + "template_code": "stroke_swallow", + "is_required_on_activation": false, + "phase": "acute", + "sort_order": 5 + }, + { + "template_code": "barthel", + "is_required_on_activation": false, + "phase": "follow_up", + "sort_order": 6 + } + ] +} diff --git a/database/migrations/2026_07_16_120000_create_care_assessment_engine_tables.php b/database/migrations/2026_07_16_120000_create_care_assessment_engine_tables.php new file mode 100644 index 0000000..5257549 --- /dev/null +++ b/database/migrations/2026_07_16_120000_create_care_assessment_engine_tables.php @@ -0,0 +1,109 @@ +id(); + $table->uuid('uuid')->unique(); + // v1 always NULL (system catalog). Reserved for future org forks. + $table->foreignId('organization_id')->nullable()->constrained('care_organizations')->nullOnDelete(); + $table->string('code'); + $table->string('name'); + $table->string('category'); // universal, disease, outcome, screening + $table->text('description')->nullable(); + $table->unsignedInteger('version')->default(1); + $table->boolean('is_current')->default(true); + $table->string('scoring_strategy')->nullable(); // sum_items, single_value, custom:Handler + $table->json('meta')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + + // Valid for v1 (system-only rows). Org clones out of scope. + $table->unique(['code', 'version']); + $table->index(['category', 'is_current', 'is_active']); + $table->index(['code', 'is_current']); + }); + + Schema::create('care_assessment_questions', function (Blueprint $table) { + $table->id(); + $table->foreignId('template_id')->constrained('care_assessment_templates')->cascadeOnDelete(); + $table->string('code'); + $table->string('section')->nullable(); + $table->string('label'); + $table->text('help_text')->nullable(); + $table->string('answer_type'); // text, number, boolean, date, single_choice, multi_choice, score_item, calculated + $table->json('options')->nullable(); + $table->boolean('is_required')->default(false); + $table->unsignedInteger('sort_order')->default(0); + $table->string('score_key')->nullable(); + $table->json('validation')->nullable(); + $table->timestamps(); + + $table->unique(['template_id', 'code']); + $table->index(['template_id', 'sort_order']); + }); + + Schema::create('care_assessments', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('branch_id')->nullable()->constrained('care_branches')->nullOnDelete(); + $table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete(); + // Frozen template version for historical integrity. + $table->foreignId('template_id')->constrained('care_assessment_templates')->restrictOnDelete(); + $table->foreignId('consultation_id')->nullable()->constrained('care_consultations')->nullOnDelete(); + $table->foreignId('visit_id')->nullable()->constrained('care_visits')->nullOnDelete(); + // patient_pathway_id added in PR 5 when pathway tables exist. + $table->foreignId('practitioner_id')->nullable()->constrained('care_practitioners')->nullOnDelete(); + $table->string('status')->default('draft'); // draft, completed, cancelled + $table->timestamp('assessed_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->string('completed_by')->nullable(); + $table->string('started_by')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['patient_id', 'assessed_at']); + $table->index(['consultation_id']); + $table->index(['owner_ref', 'status']); + $table->index(['template_id', 'status']); + $table->index(['patient_id', 'template_id', 'status']); + }); + + Schema::create('care_assessment_answers', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('assessment_id')->constrained('care_assessments')->cascadeOnDelete(); + $table->foreignId('question_id')->constrained('care_assessment_questions')->restrictOnDelete(); + $table->text('value_text')->nullable(); + $table->decimal('value_number', 12, 4)->nullable(); + $table->boolean('value_boolean')->nullable(); + $table->dateTime('value_date')->nullable(); + $table->json('value_json')->nullable(); + $table->timestamps(); + + $table->unique(['assessment_id', 'question_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('care_assessment_answers'); + Schema::dropIfExists('care_assessments'); + Schema::dropIfExists('care_assessment_questions'); + Schema::dropIfExists('care_assessment_templates'); + } +}; diff --git a/database/migrations/2026_07_16_130000_create_care_assessment_scores_table.php b/database/migrations/2026_07_16_130000_create_care_assessment_scores_table.php new file mode 100644 index 0000000..64236ca --- /dev/null +++ b/database/migrations/2026_07_16_130000_create_care_assessment_scores_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('owner_ref')->index(); + $table->foreignId('assessment_id')->constrained('care_assessments')->cascadeOnDelete(); + $table->string('template_code')->index(); + $table->decimal('total_score', 12, 4)->nullable(); + $table->decimal('max_score', 12, 4)->nullable(); + $table->json('subscores')->nullable(); + $table->string('severity_label')->nullable(); + $table->timestamp('computed_at'); + $table->timestamps(); + + $table->unique('assessment_id'); + $table->index(['owner_ref', 'template_code', 'computed_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('care_assessment_scores'); + } +}; diff --git a/database/migrations/2026_07_16_140000_create_care_clinical_pathway_tables.php b/database/migrations/2026_07_16_140000_create_care_clinical_pathway_tables.php new file mode 100644 index 0000000..09c3eb1 --- /dev/null +++ b/database/migrations/2026_07_16_140000_create_care_clinical_pathway_tables.php @@ -0,0 +1,82 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('code')->unique(); + $table->string('name'); + $table->text('description')->nullable(); + $table->json('match_rules')->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->json('meta')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->index(['is_active', 'sort_order']); + }); + + Schema::create('care_pathway_templates', function (Blueprint $table) { + $table->id(); + $table->foreignId('pathway_id')->constrained('care_clinical_pathways')->cascadeOnDelete(); + $table->string('template_code'); + $table->boolean('is_required_on_activation')->default(false); + $table->string('phase')->default('any'); // acute, follow_up, any + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['pathway_id', 'template_code']); + $table->index(['template_code']); + }); + + Schema::create('care_patient_pathways', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('patient_id')->constrained('care_patients')->cascadeOnDelete(); + $table->foreignId('pathway_id')->constrained('care_clinical_pathways')->restrictOnDelete(); + $table->string('status')->default('active'); // active, resolved, inactive + $table->timestamp('activated_at'); + $table->string('activated_by')->nullable(); + $table->foreignId('activation_consultation_id')->nullable()->constrained('care_consultations')->nullOnDelete(); + $table->string('activation_diagnosis_text')->nullable(); + $table->timestamp('resolved_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['patient_id', 'status']); + $table->index(['patient_id', 'pathway_id', 'status']); + }); + + Schema::table('care_assessments', function (Blueprint $table) { + $table->foreignId('patient_pathway_id') + ->nullable() + ->after('visit_id') + ->constrained('care_patient_pathways') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('care_assessments', function (Blueprint $table) { + $table->dropConstrainedForeignId('patient_pathway_id'); + }); + Schema::dropIfExists('care_patient_pathways'); + Schema::dropIfExists('care_pathway_templates'); + Schema::dropIfExists('care_clinical_pathways'); + } +}; diff --git a/database/seeders/AssessmentTemplateSeeder.php b/database/seeders/AssessmentTemplateSeeder.php new file mode 100644 index 0000000..becd581 --- /dev/null +++ b/database/seeders/AssessmentTemplateSeeder.php @@ -0,0 +1,131 @@ +command?->warn("No assessment packs directory at {$dir}"); + + return; + } + + $files = collect(File::files($dir)) + ->filter(fn ($file) => str_ends_with(strtolower($file->getFilename()), '.json')) + ->sortBy(fn ($file) => $file->getFilename()) + ->values(); + + if ($files->isEmpty()) { + $this->command?->warn('No assessment JSON packs found.'); + + return; + } + + foreach ($files as $file) { + $this->seedPack($file->getPathname()); + } + } + + public function seedPack(string $path): AssessmentTemplate + { + $raw = File::get($path); + $data = json_decode($raw, true); + if (! is_array($data) || ! isset($data['template'], $data['questions'])) { + throw new RuntimeException("Invalid assessment pack: {$path}"); + } + + $t = $data['template']; + foreach (['code', 'name', 'category', 'version'] as $required) { + if (! array_key_exists($required, $t)) { + throw new RuntimeException("Assessment pack missing template.{$required}: {$path}"); + } + } + + return DB::transaction(function () use ($t, $data, $path) { + $template = AssessmentTemplate::withTrashed() + ->whereNull('organization_id') + ->where('code', $t['code']) + ->where('version', (int) $t['version']) + ->first(); + + $attributes = [ + 'organization_id' => null, + 'code' => $t['code'], + 'name' => $t['name'], + 'category' => $t['category'], + 'description' => $t['description'] ?? null, + 'version' => (int) $t['version'], + 'is_current' => (bool) ($t['is_current'] ?? true), + 'is_active' => (bool) ($t['is_active'] ?? true), + 'scoring_strategy' => $t['scoring_strategy'] ?? null, + 'meta' => $t['meta'] ?? null, + 'deleted_at' => null, + ]; + + if ($template) { + if ($template->trashed()) { + $template->restore(); + } + $template->fill(collect($attributes)->except('deleted_at')->all())->save(); + } else { + $template = AssessmentTemplate::create(collect($attributes)->except('deleted_at')->all()); + } + + if ($template->is_current) { + AssessmentTemplate::query() + ->whereNull('organization_id') + ->where('code', $template->code) + ->where('id', '!=', $template->id) + ->where('is_current', true) + ->update(['is_current' => false]); + } + + // Replace questions for this template version (stable codes within version). + AssessmentQuestion::query()->where('template_id', $template->id)->delete(); + + $sort = 0; + foreach ($data['questions'] as $q) { + if (empty($q['code']) || empty($q['label']) || empty($q['answer_type'])) { + throw new RuntimeException("Invalid question in pack {$path}"); + } + $sort++; + AssessmentQuestion::create([ + 'template_id' => $template->id, + 'code' => $q['code'], + 'section' => $q['section'] ?? null, + 'label' => $q['label'], + 'help_text' => $q['help_text'] ?? null, + 'answer_type' => $q['answer_type'], + 'options' => $q['options'] ?? null, + 'is_required' => (bool) ($q['is_required'] ?? false), + 'sort_order' => (int) ($q['sort_order'] ?? $sort), + 'score_key' => $q['score_key'] ?? null, + 'validation' => $q['validation'] ?? null, + ]); + } + + $this->command?->info(sprintf( + 'Seeded assessment pack %s v%d (%d questions)', + $template->code, + $template->version, + count($data['questions']), + )); + + return $template->fresh('questions'); + }); + } +} diff --git a/database/seeders/ClinicalPathwaySeeder.php b/database/seeders/ClinicalPathwaySeeder.php new file mode 100644 index 0000000..ac0f1c4 --- /dev/null +++ b/database/seeders/ClinicalPathwaySeeder.php @@ -0,0 +1,95 @@ +command?->warn("No pathway packs directory at {$dir}"); + + return; + } + + $files = collect(File::files($dir)) + ->filter(fn ($file) => str_ends_with(strtolower($file->getFilename()), '.json')) + ->sortBy(fn ($file) => $file->getFilename()) + ->values(); + + foreach ($files as $file) { + $this->seedPack($file->getPathname()); + } + } + + public function seedPack(string $path): ClinicalPathway + { + $data = json_decode(File::get($path), true); + if (! is_array($data) || ! isset($data['pathway'])) { + throw new RuntimeException("Invalid pathway pack: {$path}"); + } + + $p = $data['pathway']; + if (empty($p['code']) || empty($p['name'])) { + throw new RuntimeException("Pathway pack missing code/name: {$path}"); + } + + return DB::transaction(function () use ($p, $data, $path) { + $pathway = ClinicalPathway::withTrashed()->where('code', $p['code'])->first(); + + $attributes = [ + 'code' => $p['code'], + 'name' => $p['name'], + 'description' => $p['description'] ?? null, + 'match_rules' => $p['match_rules'] ?? null, + 'is_active' => (bool) ($p['is_active'] ?? true), + 'sort_order' => (int) ($p['sort_order'] ?? 0), + 'meta' => $p['meta'] ?? null, + ]; + + if ($pathway) { + if ($pathway->trashed()) { + $pathway->restore(); + } + $pathway->fill($attributes)->save(); + } else { + $pathway = ClinicalPathway::create($attributes); + } + + PathwayTemplate::query()->where('pathway_id', $pathway->id)->delete(); + + foreach ($data['templates'] ?? [] as $i => $binding) { + if (empty($binding['template_code'])) { + throw new RuntimeException("Pathway pack binding missing template_code: {$path}"); + } + PathwayTemplate::create([ + 'pathway_id' => $pathway->id, + 'template_code' => $binding['template_code'], + 'is_required_on_activation' => (bool) ($binding['is_required_on_activation'] ?? false), + 'phase' => $binding['phase'] ?? PathwayTemplate::PHASE_ANY, + 'sort_order' => (int) ($binding['sort_order'] ?? ($i + 1)), + ]); + } + + $this->command?->info(sprintf( + 'Seeded pathway %s (%d template bindings)', + $pathway->code, + count($data['templates'] ?? []), + )); + + return $pathway->fresh('templates'); + }); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..f6c95a3 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,16 @@ +call([ + AssessmentTemplateSeeder::class, + ClinicalPathwaySeeder::class, + ]); + } +} diff --git a/docs/assessment-instrument-licensing.md b/docs/assessment-instrument-licensing.md new file mode 100644 index 0000000..13974b4 --- /dev/null +++ b/docs/assessment-instrument-licensing.md @@ -0,0 +1,88 @@ +# Assessment instrument licensing review (pre-GA) + +**Status:** Working checklist for legal/clinical review before commercial GA packaging +**Date:** 2026-07-16 +**Related:** `docs/layered-clinical-assessment-engine.md`, content packs under `database/data/assessments/` + +This document lists third-party and platform-authored instruments shipped as Care content packs. **Shipping code and seed data does not replace a formal legal review** for redistribution, branding, or clinical product claims in your jurisdiction. + +## Summary + +| Risk tier | Instruments | Guidance | +|-----------|-------------|----------| +| **Platform-authored** | universal_intake, stroke_swallow, stroke_clinical, diabetes_core, outcome_core, disease “_core” packs (HF, CKD, HTN, asthma, dementia, cancer, pregnancy, orthopaedics) | Own checklist content; still not a substitute for local clinical governance | +| **Public domain / widely free clinical tools** | NIHSS (NINDS/NIH origin) | Confirm training/certification requirements for clinical use; attribution in pack `meta.attribution` | +| **Standard scales — verify commercial rights** | mRS, Barthel, GCS, CAT (if used), mMRC, MMSE, MoCA, UPDRS, Hoehn & Yahr, NYHA, ECOG | Legal review before selling packs as a branded clinical product; some require licenses for electronic redistribution | +| **Names only / user-entered scores** | MoCA/MMSE/UPDRS fields in dementia/PD packs are **score entry fields**, not full electronic instruments | Prefer facility-owned paper/electronic licensed tools; Care stores the numeric result | + +## Pack-by-pack notes + +### Platform-authored (lower IP risk) + +- `universal_intake`, `outcome_core`, `stroke_clinical`, `stroke_swallow`, `diabetes_core` +- `heart_failure_core`, `ckd_core`, `hypertension_core`, `asthma_core`, `cancer_core`, `pregnancy_core`, `orthopaedics_core`, `dementia_core` (except MMSE/MoCA entry) + +**Action:** Clinical validation by medical lead; no third-party license expected for checklist wording authored by Ladill. + +### NIH Stroke Scale (`nihss`) + +- Origin: NIH/NINDS public clinical tool +- Pack includes item wording adapted for electronic capture +- **Action:** Confirm local training; keep attribution; do not claim NIH endorsement + +### Modified Rankin Scale (`mrs`) + +- Widely used disability scale +- **Action:** Verify electronic redistribution / commercial packaging rights with counsel before GA + +### Barthel Index (`barthel`) + +- ADL scale (Mahoney & Barthel lineage) +- **Action:** Licensing review for commercial electronic forms + +### Glasgow Coma Scale (`gcs`) + +- Teasdale & Jennett +- **Action:** Confirm any trademark/use restrictions for branded products; clinical use is standard + +### COPD pack (`copd_core`) + +- Includes **mMRC** scored item and free-entry **CAT** total (not full CAT questionnaire) +- **Action:** Full CAT electronic form may require GSK/community licensing — current pack only stores a numeric CAT score + +### Parkinson's (`parkinsons_core`) + +- Hoehn & Yahr stages; optional **UPDRS total** as a number (not full UPDRS form) +- **Action:** Full MDS-UPDRS electronic use requires MDS permission + +### Dementia (`dementia_core`) + +- Optional **MMSE** / **MoCA** numeric fields only +- **Action:** Do not embed full MMSE/MoCA item banks without licenses; MoCA especially is restricted + +### Heart failure (`heart_failure_core`) + +- **NYHA** class as score_item +- **Action:** NYHA is a functional classification; low typical IP risk; still document clinical source + +### Cancer (`cancer_core`) + +- **ECOG** performance status +- **Action:** Standard oncology scale; confirm branding claims + +## Pre-GA checklist + +1. [ ] Medical lead signs off clinical appropriateness of each pack for target markets +2. [ ] Counsel reviews packs marked “verify commercial rights” above +3. [ ] Attribution strings in JSON `meta.attribution` remain accurate +4. [ ] Marketing copy does **not** claim “certified NIHSS training” or endorsement by instrument owners without agreement +5. [ ] For MoCA/MMSE/UPDRS/CAT: either obtain licenses for full forms or keep score-entry-only fields (current design) +6. [ ] Facility BAAs/privacy policies cover export of assessments (including FHIR export) + +## Export & interoperability + +FHIR R4 export (`FhirAssessmentExporter`) emits Questionnaire + QuestionnaireResponse for interchange. It does not grant rights to redistribute third-party scale text outside Care; recipients remain responsible for their own compliance. + +## Contact + +Escalate open license questions to product + legal before enabling assessments engine by default for all production orgs. diff --git a/docs/layered-clinical-assessment-engine.md b/docs/layered-clinical-assessment-engine.md new file mode 100644 index 0000000..4c39bb7 --- /dev/null +++ b/docs/layered-clinical-assessment-engine.md @@ -0,0 +1,1468 @@ +# Layered Clinical Assessment Engine for Ladill Care + +| Field | Value | +|-------|-------| +| **Document title** | Layered Clinical Assessment Engine | +| **Author** | Ladill Care Engineering | +| **Date** | 2026-07-16 | +| **Status** | Approved for implementation | +| **Audience** | Senior engineers working in the Ladill Care Laravel codebase | +| **Related surfaces** | Consultations, patients, diagnoses, vitals, investigations, reports | + +--- + +## Overview + +Ladill Care today supports patient registration, visits, consultations, free-text symptoms/notes, typed vital signs, free-text diagnoses, lab orders, prescriptions, and billing. What it does **not** yet support is structured clinical assessment across specialties: instruments such as NIHSS, mRS, NYHA, HbA1c foot exams, longitudinal outcome tracking, or automatic activation of disease-specific forms when a diagnosis is recorded. + +This design introduces a **layered, metadata-driven clinical assessment engine**. Every patient completes a universal intake (Layer 1). When a clinician records or selects one or more diagnoses, matching **clinical pathways** activate (Layer 2) and load **disease-specific assessment templates** (Layer 3). Independent of disease, **generic outcome measures** (Layer 4) support longitudinal monitoring. The engine is hybrid: hot clinical data (demographics, allergies, vitals, diagnoses, labs) remains typed relational tables; specialty instruments and structured sections not yet modeled become template → question → answer instances. + +The goal is to scale to many specialties and comorbidities without schema churn, without disease-specific patient columns, and without forcing disease modules at registration time. + +--- + +## Background & Motivation + +### Current state (codebase facts) + +Multi-tenant healthcare app (`care.ladill.com`) with `owner_ref` + `organization_id` tenancy, UUIDs on public entities, soft deletes, Blade + Alpine UI, and service-layer business logic. + +**Layer 1 is partially present as typed tables:** + +| Concern | Table / model | Notes | +|---------|---------------|-------| +| Demographics | `care_patients` / `Patient` | name, DOB, gender, national_id, phone, email, address, city, region | +| Emergency contacts | `care_emergency_contacts` / `EmergencyContact` | | +| Insurance | `care_insurance_policies` / `InsurancePolicy` | | +| Allergies | `care_patient_allergies` / `PatientAllergy` | allergen, severity | +| Chronic conditions | `care_patient_conditions` / `PatientCondition` | free-text condition, not pathway-linked | +| Family history | `care_patient_family_history` / `PatientFamilyHistory` | | +| Vitals | `care_vital_signs` / `VitalSign` | BP, pulse, temp, weight, height, SpO₂, RR — **typed, consultation-scoped** | +| Diagnoses | `care_diagnoses` / `Diagnosis` | optional code + description, primary flag — **free text** | +| Labs | `care_investigation_*` | typed catalog + request + result; `care_investigation_result_values` uses a **single string** `value` column (EAV-like parameters, not typed columns) | +| Consultation narrative | `care_consultations.symptoms`, `.clinical_notes` | free text only | + +**Clinical workflow already implemented:** + +``` +Appointment check-in → Visit → Consultation (draft) + → Vitals (typed rows) + → Symptoms / clinical notes (text) + → Diagnoses (sync-delete-recreate in ConsultationService::syncDiagnoses) + → Investigations / prescriptions / complete +``` + +Key entry points: + +- UI: `resources/views/care/consultations/show.blade.php` +- Web: `App\Http\Controllers\Care\ConsultationController` +- API: `App\Http\Controllers\Api\ConsultationController` +- Domain: `App\Services\Care\ConsultationService` +- Auth: `CarePermissions` roles (`doctor`, `nurse`, …) + `ScopesToAccount` / `BelongsToOwner` +- Audit: `AuditLogger::record(...)` with actions listed in `config/care.php` → `audit_actions` +- Reports: `ReportService::clinicalReport` groups diagnosis descriptions only +- Seeders: `database/seeders/` currently has **no** assessment content-pack pattern (greenfield for this engine) + +### Pain points + +1. **Unstructured HPI** — symptoms are a textarea; no duration, pain score, or systematic functional status. +2. **No specialty instruments** — stroke/diabetes/COPD scoring cannot be captured, scored, or trended. +3. **Diagnosis is dead data for workflow** — recording “Ischaemic stroke” does not surface NIHSS or swallow assessment. +4. **Comorbidity gap** — `care_patient_conditions` is a static list; it does not load assessment packs. +5. **Longitudinal gap** — vitals append rows per consultation; there is no first-class QoL, falls, adherence, or readmission outcome series. +6. **Schema risk** — adding NIHSS as columns on patients/consultations would explode tables and break when guidelines change. + +### Why now + +Ladill Care is positioned for multi-specialty facilities (see `config/care.php` department types: maternity, dental, physiotherapy, etc.). Without a modular assessment engine, each specialty request becomes a one-off schema + UI project. A template-driven engine amortizes that cost. + +--- + +## Goals & Non-Goals + +### Goals + +1. **Universal first** — register and consult any patient without selecting a specialty module. +2. **Diagnosis-activated pathways** — linking a diagnosis (or explicit clinician selection) activates one or more clinical pathways and their assessment templates. +3. **Comorbidity-safe** — N active pathways ⇒ N concurrent template sets on the same patient/consultation. +4. **Metadata-driven instruments** — new diseases and guideline updates ship as seed/content packs, not migrations of clinical columns. +5. **Hybrid storage** — keep typed tables for demographics, vitals, diagnoses, labs; use template answers for instruments and missing structured universal sections. +6. **Longitudinal by design** — every assessment instance is dated, patient-scoped, optionally consultation/visit-linked, with status lifecycle. +7. **Fit existing patterns** — `care_` tables, UUIDs, soft deletes, `owner_ref` on **runtime** data, organization/branch scope, `CarePermissions`, `AuditLogger`, service classes, Blade sections on consultation show. +8. **Incremental delivery** — ship engine + universal template first; pathways; scored stroke MVP (NIHSS + mRS); then more packs. + +### Non-Goals + +1. **Full ICD-10/SNOMED browser** in v1 — free-text + optional code remains; pathway matching uses configurable code/keyword rules, not a complete terminology server. +2. **Replacing typed vitals or lab catalog** with EAV — those stay relational. +3. **All twelve specialty modules in the first release** — Stroke MVP (NIHSS + mRS) then Diabetes; others follow as content packs. +4. **Treatment plan engine / care pathways as order sets** — assessment only; orders stay prescriptions/investigations. +5. **Offline mobile form runtime** — web + existing API patterns first. +6. **Clinical decision support alerts** (e.g. thrombolysis eligibility) beyond computed scores and basic validation. +7. **Patient self-assessment portal** in v1 (may reuse templates later). +8. **Migrating historical free-text symptoms into structured answers** automatically. +9. **Org-level pathway match_rules overrides** in v1 (platform rules only). +10. **Auto-copy between symptoms textarea and universal intake** in either direction. + +--- + +## Key Decisions + +| # | Decision | Rationale | +|---|----------|-----------| +| KD-1 | **Hybrid model**: typed clinical hot path + template-driven instruments | Matches agreed principles; vitals/labs/diagnoses already typed and report-friendly (`ReportService`, consultation UI). Avoids EAV for BP/weight used every visit. | +| KD-2 | **Assessment instances are first-class rows** (`care_assessments`), not JSON blobs on consultation | Enables longitudinal queries, multi-template per visit, draft/complete status, audit subject IDs. | +| KD-3 | **Answers store values in typed columns** (`value_text`, `value_number`, `value_boolean`, `value_date`, `value_json`) keyed by `question_id`; **exactly one authoritative column per answer_type**; unused columns **must be null** | Improves on string-only `care_investigation_result_values.value` for numeric scoring (NIHSS, CAT). Write/read invariants prevent dual-column drift. | +| KD-4 | **Pathways are overlays, not patient types** | Patient registration stays universal (`PatientService` / `_form.blade.php` unchanged for specialty). Pathways attach via `care_patient_pathways`. | +| KD-5 | **Pathway activation is explicit + rule-assisted** | Auto-suggest from diagnosis code/description match; clinician confirms. Avoids silent activation on ambiguous free-text diagnoses. | +| KD-6 | **Catalog is platform-global (no `owner_ref`); runtime always tenant-scoped** | Clinical instruments (NIHSS) are platform-standard. **Intentionally different** from org-scoped `care_investigation_types`. See [Catalog tenancy contract](#catalog-tenancy-contract). Org template forks are **out of v1**. | +| KD-7 | **Score materialization table ships with scoring PR (PR 4), not engine schema PR** | Keeps PR 1 small; scores needed before specialty instruments (KD-7 timing with specialty module). | +| KD-8 | **Link assessments to consultation when captured in-consult; always require `patient_id`** | Supports in-visit workflow and standalone follow-up assessments without open consultation. | +| KD-9 | **Capture gate = route ability + `assertCaptureAllowed` with admin bypass** | Flat abilities cannot express per-template rules alone. Nurses get `assessments.view` + `assessments.capture`; doctors get those + `assessments.manage` + `pathways.manage`. **Facility owners are `hospital_admin`** (`OrganizationResolver::ensureOwnerMember`), not `doctor` — admins must capture disease instruments. Rule: `hospital_admin` / `super_admin` always allowed; else member `role` must be in `template.meta.capture_roles`. See [Permissions](#permissions-carepermissions). | +| KD-10 | **Content packs as versioned JSON under `database/data/assessments/` + seeders** | Reproducible deploys; no admin form builder in v1. Canonical schema required (see [Seed JSON schema](#seedjson-content-pack-schema)). | +| KD-11 | **Do not expand `care_patients` with social/functional columns** | Put structured HPI / social history / functional status into universal assessment templates so they can evolve without migrations. | +| KD-12 | **Diagnoses remain on `care_diagnoses`** | Pathway engine reads diagnoses; does not replace them. Activation snapshots `activation_diagnosis_text` only — **never** diagnosis row FKs (`syncDiagnoses` deletes/recreates IDs every save). | +| KD-13 | **Branch visibility matches consultations** | List/show assessments apply `OrganizationResolver::branchScope` like `ConsultationController::authorizeConsultation`. Branch-scoped members only see assessments for their branch; admins without branch scope see org-wide. Write: `branch_id` from visit (preferred) else `patient.branch_id`. | +| KD-14 | **Completed assessments are immutable in v1** | `PUT` / complete on non-draft → **422**. No amend flow. Soft-delete assessment for administrative removal only (answers hard-owned by assessment; queries always scope non-deleted assessments). | +| KD-15 | **Consultation free-text remains narrative source of truth for HPI** | `care_consultations.symptoms` / `clinical_notes` stay primary narrative for discharge-style notes and existing UI. Universal intake is an **optional structured overlay**. Show both on chart; **no auto-copy either direction** in v1. | +| KD-16 | **Pathway bindings store `template_code` only** (no `template_id` on `care_pathway_templates`) | Resolve `is_current` at assessment start so version bumps do not break pathway rows. Historical assessments keep their own `template_id` FK. | +| KD-17 | **One active pathway per (patient, pathway); one draft per (patient, template, consultation)** | Enforced in transactions with `lockForUpdate`; see [Concurrency & uniqueness](#concurrency--uniqueness). | +| KD-18 | **Plan packaging: engine + disease capture free; advanced analytics Enterprise** | Core assessment engine, universal intake, pathway activation, and disease pack capture are available without Enterprise gate. Org-level outcome trends / advanced assessment analytics are Enterprise-packaged (see Rollout). | +| KD-19 | **Single Stroke pathway; subtype is assessment data (includes TIA)** | TIA, ischaemic, and haemorrhagic are not separate pathways. Capture subtype on stroke clinical instruments / match TIA keywords to the same `stroke` pathway. | +| KD-20 | **Pathways and `care_patient_conditions` stay independent** | Activating a pathway does **not** auto-add or sync chronic condition list rows. Clinicians maintain conditions separately. | + +--- + +## Proposed Design + +### Layer model + +```mermaid +flowchart TB + subgraph L1["Layer 1 — Universal"] + Demo[Demographics typed] + EC[Emergency / Insurance typed] + All[Allergies / Family / Conditions typed] + UnivTpl[Universal intake template] + Vitals[Vitals typed] + Narrative[Consultation symptoms/notes free text] + end + + subgraph L2["Layer 2 — Pathway selection"] + Dx[Persisted diagnoses] + Match[Pathway matcher] + PP[Patient pathways active] + end + + subgraph L3["Layer 3 — Disease modules"] + Stroke[Stroke templates] + DM[Diabetes templates] + Other[Other content packs...] + end + + subgraph L4["Layer 4 — Outcomes"] + OutTpl[Generic outcome template] + Scores[Score / trend series] + end + + Demo --> UnivTpl + Narrative -.->|no auto-copy v1| UnivTpl + UnivTpl --> Dx + Vitals --> Dx + Dx --> Match + Match --> PP + PP --> Stroke + PP --> DM + PP --> Other + Stroke --> OutTpl + DM --> OutTpl + UnivTpl --> OutTpl + OutTpl --> Scores +``` + +### Clinical workflow (target) + +```mermaid +sequenceDiagram + participant R as Reception + participant N as Nurse + participant D as Doctor + participant Eng as AssessmentEngine + participant DB as DB + + R->>DB: Register patient (typed demographics) + R->>DB: Check-in visit / appointment + N->>DB: Record vitals (care_vital_signs) + N->>Eng: Start universal intake (capture ability) + Eng->>DB: care_assessments + answers + D->>DB: Save consultation notes + diagnoses + Note over D,Eng: Suggestions use persisted diagnoses only + D->>Eng: GET pathway-suggestions + Eng-->>D: Stroke, Diabetes + match reasons + D->>Eng: Activate pathways (confirm) + Eng->>DB: care_patient_pathways + required drafts + D->>Eng: Complete NIHSS / mRS + Eng->>DB: Answers + scores + D->>DB: Plan (Rx / labs) + complete consultation + Note over Eng,DB: Follow-up: outcome template + disease follow-ups +``` + +### Architecture (application) + +```mermaid +flowchart LR + UI[Blade consultation / patient show] + API[API AssessmentController] + Ctl[Care\\AssessmentController
PathwayController] + Svc[AssessmentService
PathwayService
ScoringService] + Feat[CareFeatures rollout flags] + Match[PathwayMatcher] + Models[(Catalog global
Runtime tenant-scoped)] + Exist[ConsultationService
Patient / Diagnosis / VitalSign] + + UI --> Ctl + UI --> Feat + API --> Svc + Ctl --> Svc + Ctl --> Feat + Svc --> Models + Svc --> Match + Exist --> Match + Svc --> Exist +``` + +Place services under `app/Services/Care/` alongside `ConsultationService`, `InvestigationService`. Controllers under `app/Http/Controllers/Care/` and `Api/`. Follow `ScopesToAccount` + `authorizeAbility` patterns from `ConsultationController`. + +### Domain concepts + +| Concept | Definition | +|---------|------------| +| **Template** | Versioned form definition (e.g. `universal_intake`, `nihss`, `mrs`, `outcome_core`). | +| **Question** | Field within a template: code, label, answer type, options, validation, scoring weight, section. | +| **Assessment** | Completed or draft instance of a template for a patient at a point in time. | +| **Answer** | Value for one question on one assessment (single typed column authoritative). | +| **Pathway** | Clinical module registry entry (Stroke, Diabetes, …) binding diagnosis match rules to templates by **code**. | +| **Patient pathway** | Active association of a pathway to a patient (onset, status, activating diagnosis text snapshot). | +| **Score** | Materialized instrument total/subscores for an assessment. | + +### Catalog tenancy contract + +This engine **intentionally diverges** from `care_investigation_types` (which is org-scoped with `owner_ref` + `organization_id`). + +| Layer | Tables | Tenancy | Authz | +|-------|--------|---------|-------| +| **Catalog** | `care_assessment_templates`, `care_assessment_questions`, `care_clinical_pathways`, `care_pathway_templates` | **No `owner_ref`**. Platform-global rows only in v1. `organization_id` is **null** and reserved for a future org-fork phase (not used in v1 writes). | Read: any authenticated Care member with `assessments.view` (or consultation context). Write: **not via product UI in v1** — only seeders/migrations/deploy. No `authorizeOwner` on catalog models. | +| **Runtime** | `care_assessments`, `care_assessment_answers`, `care_assessment_scores`, `care_patient_pathways` | Always `owner_ref` + `organization_id` copied from patient (and `branch_id` per KD-13). Models use `BelongsToOwner`. | `authorizeOwner` + org check + branchScope like consultations. | + +**Models / traits:** + +- Catalog models: **do not** use `BelongsToOwner`. +- Runtime models: **do** use `BelongsToOwner` + soft deletes where specified. + +**Canonical catalog queries:** + +```php +// Current system template by code (v1: all catalog rows are system) +AssessmentTemplate::query() + ->whereNull('organization_id') + ->where('code', $code) + ->where('is_current', true) + ->where('is_active', true) + ->whereNull('deleted_at') + ->firstOrFail(); + +// Active pathways for matching +ClinicalPathway::query() + ->where('is_active', true) + ->whereNull('deleted_at') + ->orderBy('sort_order') + ->get(); +``` + +**Seeding across tenants:** one shared catalog in the app database; all `owner_ref` tenants read the same NIHSS definition. White-label multi-DB deploys each seed their own catalog (same JSON packs). There is no per-tenant catalog copy in v1. + +**Why not `owner_ref` on catalog:** system NIHSS is not owned by any hospital account; putting a seeder `owner_ref` would break `authorizeOwner` for other tenants or force awkward sentinel values. + +### Template categories + +| `category` | Purpose | Default `meta.capture_roles` | +|------------|---------|------------------------------| +| `universal` | Layer 1 structured intake | `["doctor","nurse"]` | +| `disease` | Layer 3 instruments | `["doctor"]` | +| `outcome` | Layer 4 longitudinal | `["doctor","nurse"]` | +| `screening` | Optional later | `["doctor","nurse"]` | + +### Answer types and write/read invariants + +Supported `answer_type` values. **Exactly one storage column is authoritative**; on write, all other value columns **must be set to null**. + +| Type | Authoritative column | Payload shape | UI control | +|------|----------------------|---------------|------------| +| `text` | `value_text` | string | input | +| `textarea` | `value_text` | string | textarea | +| `number` | `value_number` | numeric | number input | +| `integer` | `value_number` | integer (stored as decimal) | number input | +| `boolean` | `value_boolean` | bool | checkbox | +| `date` | `value_date` | `Y-m-d` | date | +| `datetime` | `value_date` | ISO datetime | datetime | +| `single_choice` | `value_text` | option `code` string | radio/select | +| `multi_choice` | `value_json` | array of option codes | checkboxes | +| `scale` | `value_number` | number within min/max | Likert / 0–10 | +| `score_item` | `value_number` | numeric score (from choice or direct) | scored choice (NIHSS item) | +| `calculated` | `value_number` | **not client-writable** in v1 | read-only display | + +**Normalization (`AssessmentService::normalizeValue`):** + +1. Validate raw payload against type + `validation` / `options`. +2. Build row with **only** the authoritative column set; explicitly null the others. +3. For `score_item` + `single_choice`-style choices: client may send option `code`; server maps `options.choices[].score` → `value_number`, and may also store code in `value_text` **only if** type were `single_choice`. For pure `score_item`, store score in `value_number` only (code optional in `value_json` as `{"code":"1"}` if audit of selection needed — v1: store score only in `value_number`). +4. For `multi_choice`: scores are **not** auto-summed unless `scoring_strategy` handles them (v1 disease instruments use `score_item`, not multi_choice, for scored totals). +5. **`calculated` fields (v1 deferred for formulas):** if present, ignore client input; recompute only if `options.formula` is absent — **v1 ships no calculated questions**. Reserve type for PR later; seed packs must not use `calculated` until formula support lands. + +**Read path:** UI and scoring always read the authoritative column for the question’s `answer_type`; never fall back to another column. + +Options JSON on questions: + +```json +{ + "choices": [ + {"code": "0", "label": "No symptoms at all", "score": 0}, + {"code": "1", "label": "No significant disability", "score": 1} + ], + "min": 0, + "max": 6, + "unit": null +} +``` + +### Scoring contract + +`ScoringService` runs on **complete** (and optionally `preview` on draft save for live totals — preview does not persist). + +| `scoring_strategy` | Behavior | +|--------------------|----------| +| `null` / empty | No score row required (e.g. free-text heavy forms). Complete still validates required questions. | +| `sum_items` | Sum `value_number` for all questions with `answer_type = score_item` (and optional `score_key` buckets into `subscores`). `max_score` from sum of each item’s `options.max` or max choice score. | +| `single_value` | Exactly one scored question (usually `score_item` or `scale`); `total_score` = that `value_number`. Used for mRS. | +| `custom:{Handler}` | Map to `App\Services\Care\Scoring\{Handler}` implementing `ScoresAssessment` interface (`score(Assessment): array{total, max, subscores, severity_label}`). Register via strategy string only — no dynamic class from user input. | + +**Complete-time validation:** + +1. All `is_required` questions have non-null authoritative values. +2. If strategy is `sum_items` or `single_value`, all `score_item` questions required for the instrument must be present (treat missing as 422). +3. On success: upsert `care_assessment_scores`; audit `assessment.completed`. +4. **Idempotent complete:** if already `completed`, return 422 (no re-complete) per KD-14. +5. Scoring failure: `Log::warning` with `template_code`, `assessment_uuid`, exception message; do not complete; return 422. + +### Pathway matching (detailed) + +`PathwayMatcher` input: **persisted** diagnosis rows only (`code`, `description`) — see [Pathway suggestion UX](#pathway-suggestion-ux-timing). + +Rules on `care_clinical_pathways.match_rules`: + +```json +{ + "icd_prefixes": ["I60", "I61", "I62", "I63", "I64", "G45"], + "keywords": ["stroke", "cva", "tia", "transient ischaemic attack", "transient ischemic attack", "cerebrovascular", "hemiplegia", "ischaemic stroke", "ischemic stroke"], + "exclude_keywords": ["family history"] +} +``` + +TIA matches the same `stroke` pathway (KD-19); subtype is not a separate pathway. + +**Algorithm (v1, exact):** + +``` +normalize(s) = lowercase(trim(s)); collapse internal whitespace; ASCII-fold optional (v1: mb_strtolower only) +for each active pathway P: + best_rank = none + best_reason = null + for each diagnosis D: + code_n = normalize(D.code) without dots for prefix compare? → strip non-alnum for prefix: "I63.9" → "I639" then prefix match on stripped prefixes + Actually v1: strip dots from code: "I63.9" → "I639"; prefixes stored without dots "I63" matches startswith + desc_n = normalize(D.description) + if any exclude_keyword in desc_n as substring → skip this diagnosis for P + if any icd_prefix where stripped_code starts with stripped_prefix → rank=100, reason="icd_prefix:{prefix}" + else if any keyword where keyword is substring of desc_n → rank=50, reason="keyword:{keyword}" + keep max rank for P across diagnoses + if best_rank: include P once with rank + reason +sort by rank desc, then pathway.sort_order +return unique pathways (one entry per pathway_id) +``` + +**Examples:** + +| Diagnosis | Expected | +|-----------|----------| +| code `I63.9`, desc empty | Match stroke (icd_prefix I63), rank 100 | +| code empty, desc `Ischaemic stroke` | Match stroke (keyword), rank 50 | +| desc `History unclear` | No match | +| desc `Family history of stroke` with exclude `family history` | No match if exclude_keywords configured | + +**Org overrides:** out of scope v1 (Non-Goal 9). Platform `match_rules` only. + +**API suggestion payload includes match reason** for UI chips (“Matched ICD I63”, “Matched keyword: stroke”). + +### Pathway suggestion UX timing + +Diagnoses exist in DB only after `ConsultationService::save` → `syncDiagnoses` (delete-recreate). Alpine form state is **not** the source for server suggestions. + +**v1 rules:** + +1. **Server suggestions (`GET .../pathway-suggestions`)** use **persisted** diagnoses on the consultation only. After Save, show section refreshes (or client refetches). Empty if no diagnoses saved yet. +2. **Optional UX:** button **“Save diagnoses & suggest pathways”** that POSTs consultation update then redirects/refetches suggestions (single CTA). Preferred in consultation UI copy. +3. **Optional client preview (non-authoritative):** embed active pathway keyword/prefix lists once on page load for live Alpine preview of unsaved diagnosis rows; label as “Preview — save to confirm”. Must not activate pathways from preview alone. +4. **Activation snapshot:** store `activation_diagnosis_text` (concat descriptions/codes at activate time). **Never** store diagnosis row IDs (IDs are unstable under `syncDiagnoses`). + +### Consultation UI integration + +Extend `resources/views/care/consultations/show.blade.php` with new sections (same card pattern as vitals/diagnoses), gated by `CareFeatures`: + +1. **Universal assessment** — link/form for incomplete universal intake for this visit (`$canCaptureAssessment`). +2. **Active pathways** — chips; “Add clinical pathway” (`pathways.manage`). +3. **Suggested pathways** — from **saved** diagnoses + match reasons; empty state: “Save diagnoses to see suggestions”. +4. **Disease assessments** — drafts/completes for this consultation + patient; “Start NIHSS” if capture_roles allow. +5. **Outcomes** — optional follow-up CTA. + +UI flags (mirror vitals split in `ConsultationController`): + +```php +$canViewAssessments = permissions->can($member, 'assessments.view'); +$canCaptureAssessment = permissions->can($member, 'assessments.capture') + || permissions->can($member, 'assessments.manage'); +$canManagePathways = permissions->can($member, 'pathways.manage'); +// Per-template start/save: AssessmentService::assertCaptureAllowed (admin bypass + capture_roles) +``` + +Patient chart: timeline of assessments (branch-scoped per KD-13) + active pathways + latest scores. Free-text symptoms **and** latest universal intake shown as separate cards (KD-15). + +**Routes (authoritative list — no DELETE for pathways; use deactivate):** + +``` +GET /patients/{patient}/assessments +GET /patients/{patient}/assessments/create +POST /patients/{patient}/assessments +POST /consultations/{consultation}/assessments +GET /assessments/{assessment} +PUT /assessments/{assessment} +POST /assessments/{assessment}/complete +POST /assessments/{assessment}/cancel +GET /patients/{patient}/pathways +POST /patients/{patient}/pathways +POST /patients/{patient}/pathways/{patientPathway}/deactivate +GET /consultations/{consultation}/pathway-suggestions +``` + +This mirrors lab requests (`care.lab.requests.store`) as a separate POST from consultation save. + +### Hook points in existing services + +| Location | Change | +|----------|--------| +| `ConsultationService::save` | No silent pathway activation. Optional: controller after save redirects with flash “diagnoses saved — review pathway suggestions”. | +| `ConsultationService::complete` | Soft warning only if flag `assessment_required_on_complete` (default false) — non-blocking in v1. | +| `Patient` model | Relations: `pathways()`, `assessments()`. | +| `Consultation` model | Relation: `assessments()`. | +| `CarePermissions` | Abilities: `assessments.view`, `assessments.capture`, `assessments.manage`, `pathways.manage`. | +| `config/care.php` | Status enums, audit actions, template categories. | +| `database/seeders/DatabaseSeeder.php` | Call assessment/pathway seeders in deploy path (PR 3+). | + +### Dynamic form rendering + +v1: Blade partial `resources/views/care/assessments/_form.blade.php` switches on `answer_type`. Alpine for multi-item instruments. No SPA. + +```php +// Pseudocode — AssessmentService::saveAnswers +abort_unless($assessment->status === Assessment::STATUS_DRAFT, 422); +// hospital_admin/super_admin always allow; else role ∈ capture_roles +$this->assertCaptureAllowed($member, $assessment->template); + +foreach ($template->questions as $question) { + if ($question->answer_type === 'calculated') { + continue; // v1 unused + } + $raw = $payload[$question->code] ?? null; + $this->validateAnswer($question, $raw); + AssessmentAnswer::updateOrCreate( + ['assessment_id' => $assessment->id, 'question_id' => $question->id], + array_merge( + ['owner_ref' => $ownerRef], + $this->normalizeValue($question, $raw), // sets one column, nulls others + ), + ); +} +``` + +### Versioning templates + +When NIHSS items change: + +1. Insert new `care_assessment_templates` row with same `code`, incremented `version`, `is_current = true`; mark previous `is_current = false`. +2. Historical assessments keep FK to the old template version (immutable definition). +3. New assessments resolve via `template_code` → `is_current` (pathway bindings never store versioned `template_id`). +4. Unit test required: bump version; pathway binding still resolves; new start uses new version; old assessment still loads old questions. + +### Concurrency & uniqueness + +**Active patient pathway** + +- Service: `DB::transaction` + `PatientPathway::where(patient, pathway)->lockForUpdate()`. +- If row `status = active` exists → return existing (idempotent activate) or 422 “already active” (prefer **idempotent return** of existing active row). +- If `resolved` / `inactive` → create **new** row with `active` (history preserved) OR reactivate same row — v1: **create new active row** after setting old to stay resolved/inactive (audit trail). Enforce at most one `active` via transaction check (not partial unique index in MySQL without workarounds). +- Optional DB aid: generated column `active_pathway_key` = `patient_id` when status=active else NULL + unique `(active_pathway_key, pathway_id)` if MySQL version supports; otherwise service lock is sufficient for v1. + +**Draft assessments** + +- v1: **at most one draft** per `(patient_id, template_id, consultation_id)` when `consultation_id` present; when no consultation, at most one draft per `(patient_id, template_id)` with `consultation_id` null and `status=draft`. +- `start()`: if draft exists, **return existing draft** (idempotent) instead of creating duplicate. +- Multiple **completed** assessments of same template over time are allowed (longitudinal). + +### Soft-delete / cancel lifecycle + +| Status / action | Behavior | +|-----------------|----------| +| `draft` | Editable via PUT; completeable; **cancellable** via `POST .../cancel` → `status=cancelled` (not soft-deleted). | +| `completed` | Immutable; PUT/complete/cancel → **422**. Soft-delete only via admin tooling (out of product UI v1) if legally required. | +| `cancelled` | Terminal for that instance; excluded from “open assessments” lists; retained for audit. | +| Soft-delete assessment | Answers remain (FK cascade only on hard delete). Default global scope / queries: `Assessment::query()` excludes soft-deleted. **Never** list answers without joining non-deleted assessment. Hard delete only in tests. | +| Answers | No soft deletes; lifetime bound to assessment row. | + +Amend of completed assessments: **out of v1** (Open Question remains for product later; KD-14 locks complete). + +--- + +## API / Interface Changes + +### Web routes (additions to `routes/web.php` inside `care.setup` group) + +| Method | Route name | Ability | +|--------|------------|---------| +| GET `/patients/{patient}/assessments` | `care.assessments.index` | `assessments.view` | +| GET `/assessments/{assessment}` | `care.assessments.show` | `assessments.view` | +| GET `/patients/{patient}/assessments/create` | `care.assessments.create` | `assessments.capture` or `assessments.manage` | +| POST `/patients/{patient}/assessments` | `care.assessments.store` | `assessments.capture` or `assessments.manage` | +| POST `/consultations/{consultation}/assessments` | `care.consultations.assessments.store` | `assessments.capture` or `assessments.manage` | +| PUT `/assessments/{assessment}` | `care.assessments.update` | `assessments.capture` or `assessments.manage` | +| POST `/assessments/{assessment}/complete` | `care.assessments.complete` | `assessments.capture` or `assessments.manage` | +| POST `/assessments/{assessment}/cancel` | `care.assessments.cancel` | `assessments.capture` or `assessments.manage` | +| GET `/patients/{patient}/pathways` | `care.pathways.index` | `assessments.view` | +| POST `/patients/{patient}/pathways` | `care.pathways.store` | `pathways.manage` | +| POST `/patients/{patient}/pathways/{patientPathway}/deactivate` | `care.pathways.deactivate` | `pathways.manage` | +| GET `/consultations/{consultation}/pathway-suggestions` | `care.consultations.pathway-suggestions` | `consultations.view` | + +Route model binding: assessments use `uuid` (`getRouteKeyName()`), consistent with `Consultation`, `Patient`, `Visit`. + +### Request / response contracts + +#### Public ID convention (web + API) + +Care public routes bind consultations, visits, patients, and assessments by **UUID** (`getRouteKeyName()`). Request bodies that reference those entities **must accept UUID strings**, never internal bigint primary keys. + +| Public field | Resolves to | +|--------------|-------------| +| `consultation` route param / `consultation_uuid` body | `care_consultations.id` FK | +| `visit_uuid` body (optional) | `care_visits.id` FK | +| `patient` route param | `care_patients.id` FK | +| `assessment` route param | `care_assessments.id` | + +Controllers resolve UUID → model with tenant/branch checks, then pass integer FKs into services. Internal service methods may use integer IDs; **HTTP contracts never expose integer FKs as client-facing identifiers**. + +#### `POST /patients/{patient}/assessments` (or consultation-scoped store) + +```json +{ + "template_code": "universal_intake", + "consultation_uuid": null, + "visit_uuid": null, + "patient_pathway_uuid": null +} +``` + +Consultation-scoped route `POST /consultations/{consultation}/assessments` takes the consultation from the path (UUID route binding); body needs only `template_code` (+ optional pathway uuid). + +**Responses:** + +| Code | When | +|------|------| +| 201 | Created (or 200 if idempotent existing draft returned — document as 200 with existing uuid) | +| 403 | Missing ability or `assertCaptureAllowed` denies member role | +| 404 | Patient/consultation wrong tenant/branch or unknown UUID | +| 422 | Template inactive / not current / unknown code | + +#### `PUT /assessments/{assessment}` + +```json +{ + "answers": { + "chief_complaint": "Sudden right weakness", + "pain_score": 3, + "smoking_status": "former" + }, + "notes": "Optional free text" +} +``` + +Answers keyed by **question code**. Partial updates allowed (only keys present are written). Omit key → leave previous answer unchanged; send `null` → clear if question not required (required null → 422 on complete, not necessarily on draft save). + +**Responses:** 200 OK; **422** if not draft; **403** `assertCaptureAllowed`; **404** tenant. + +#### `POST /assessments/{assessment}/complete` + +Empty body or `{}`. Validates required + scoring; materializes score. + +**Responses:** 200 with assessment + score; **422** validation/scoring/not draft/already completed; **403** `assertCaptureAllowed`. + +#### `POST /assessments/{assessment}/cancel` + +Draft only → `cancelled`. **422** if completed. + +#### `GET /consultations/{consultation}/pathway-suggestions` + +```json +{ + "data": [ + { + "pathway_code": "stroke", + "pathway_name": "Stroke", + "rank": 100, + "match_reason": "icd_prefix:I63", + "already_active": false + } + ] +} +``` + +#### `POST /patients/{patient}/pathways` + +```json +{ + "pathway_code": "stroke", + "consultation_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "activation_diagnosis_text": "I63.9 Ischaemic stroke" +} +``` + +`consultation_uuid` is optional; when present, resolve to consultation (owned + org/branch scoped) and store internal `activation_consultation_id` FK. Do **not** accept integer `consultation_id` from clients. + +Creates patient pathway + required draft assessments for `is_required_on_activation` templates (resolve current template by code). Does **not** create or update `care_patient_conditions` (KD-20). + +#### `listForPatient` filters + +`template_code`, `status` (`draft|completed|cancelled`), `category`, `from`, `to` (assessed_at), `per_page` (default 20). Always branch-scoped per KD-13. + +### Service interfaces + +```php +// app/Services/Care/AssessmentService.php +class AssessmentService +{ + public function start( + Patient $patient, + string $templateCode, // resolves is_current system template + string $ownerRef, + ?Member $member, + array $context = [], // consultation?, visit?, patient_pathway? (models or internal ids after controller resolve), actor + ): Assessment; + + /** @param array $answers keyed by question code */ + public function saveAnswers( + Assessment $assessment, + string $ownerRef, + array $answers, + ?Member $member = null, + ?string $actorRef = null, + ): Assessment; + + public function complete(Assessment $assessment, string $ownerRef, ?Member $member = null, ?string $actorRef = null): Assessment; + + public function cancel(Assessment $assessment, string $ownerRef, ?Member $member = null, ?string $actorRef = null): Assessment; + + public function listForPatient(Patient $patient, string $ownerRef, array $filters = [], ?int $branchScope = null): LengthAwarePaginator; +} + +// app/Services/Care/PathwayService.php +class PathwayService +{ + /** @param iterable $diagnoses */ + public function suggest(iterable $diagnoses): Collection; // unique pathways + rank + reason + + public function activate( + Patient $patient, + ClinicalPathway $pathway, + string $ownerRef, + array $context = [], // consultation? (model after UUID resolve), activation_diagnosis_text, actor + ): PatientPathway; + + public function deactivate(PatientPathway $patientPathway, string $ownerRef, ?string $actorRef = null): PatientPathway; + + public function activeFor(Patient $patient): Collection; +} + +// app/Services/Care/ScoringService.php +class ScoringService +{ + public function materialize(Assessment $assessment): AssessmentScore; + /** @return array{total: ?float, max: ?float, subscores: array, severity_label: ?string} */ + public function preview(Assessment $assessment): array; +} + +// app/Services/Care/CareFeatures.php (rollout flags — NOT PlanService) +class CareFeatures +{ + public function enabled(Organization $organization, string $flag): bool; + // keys under settings.rollout.* +} +``` + +### Permissions (`CarePermissions`) + +| Ability | Roles (v1) | Purpose | +|---------|------------|---------| +| `assessments.view` | doctor, nurse, hospital_admin, super_admin | Read assessments/pathways | +| `assessments.capture` | doctor, nurse, hospital_admin, super_admin | Start/save/complete/cancel (subject to `assertCaptureAllowed`) | +| `assessments.manage` | doctor, hospital_admin, super_admin | Route-level capture access (same start/save/complete endpoints as capture) **plus** future admin ops; does **not** alone bypass template rules — service still runs `assertCaptureAllowed` | +| `pathways.manage` | doctor, hospital_admin, super_admin | Activate/deactivate pathways | + +**Why admin bypass matters:** `OrganizationResolver::ensureOwnerMember` creates the facility owner as **`hospital_admin`**, not `doctor`. Small clinics often have a single clinician-owner on that role. Disease templates seed `capture_roles: ["doctor"]` for clinical scope — without an explicit admin exception, owners could activate stroke pathways but get **403** on NIHSS/mRS. + +**Enforcement layers (both required):** + +1. **Route/controller:** member must have `assessments.capture` **or** `assessments.manage` (or `assessments.view` for reads; `pathways.manage` for pathway mutations). +2. **Service — single rule for all start/save/complete/cancel:** + +``` +assertCaptureAllowed(Member $member, AssessmentTemplate $template): + if member is null → 403 + if member.role in {hospital_admin, super_admin} → allow + roles = template.meta.capture_roles + ?? default_capture_roles(template.category) + // universal/outcome/screening → ["doctor","nurse"] + // disease → ["doctor"] + if member.role ∈ roles → allow + else → 403 +``` + +Notes: + +- **`hospital_admin` / `super_admin` always pass** capture checks (owner/admin clinicians). They still need a route ability (`capture` or `manage`); both roles have `*` in `CarePermissions` today, so that is satisfied. +- **`assessments.manage` does not invent a second capture matrix** — it only unlocks the same endpoints as `capture` for roles that hold manage (doctor already holds both). Template scope is **only** decided by `assertCaptureAllowed` above. +- Disease seeds keep `capture_roles: ["doctor"]`; nurses remain **403** on NIHSS; doctors **201**; hospital_admin **201** via admin branch (not by expanding seed lists to every admin role). + +**Feature tests required:** + +| Actor role | Template | Expected | +|------------|----------|----------| +| nurse | `universal_intake` | 201 | +| nurse | `nihss` | 403 | +| doctor | `nihss` | 201 | +| hospital_admin | `nihss` | 201 | +| super_admin | `nihss` | 201 | + +### Audit actions (add to `config/care.php`) + +``` +assessment.started +assessment.updated +assessment.completed +assessment.cancelled +pathway.activated +pathway.deactivated +``` + +Metadata: subject ids, `template_code`, `pathway_code` — **not** full answer payloads. + +### JSON API (PR 9) + +Mirror web under `routes/api.php` with `ScopesApiToAccount`. OpenAPI: `docs/openapi/care.yaml`. Tenant/branch tests parallel `CareLabTest` patterns. + +### Consultation payload (unchanged) + +`ConsultationController::validatedConsultationData` remains symptoms/notes/vitals/diagnoses/documents. Assessments are separate resources. + +--- + +## Data Model Changes + +### Entity relationship + +```mermaid +erDiagram + care_patients ||--o{ care_assessments : has + care_consultations ||--o{ care_assessments : context + care_visits ||--o{ care_assessments : context + care_assessment_templates ||--o{ care_assessment_questions : defines + care_assessment_templates ||--o{ care_assessments : instances + care_assessments ||--o{ care_assessment_answers : has + care_assessment_questions ||--o{ care_assessment_answers : answers + care_assessments ||--o| care_assessment_scores : score + care_clinical_pathways ||--o{ care_pathway_templates : includes + care_patients ||--o{ care_patient_pathways : enrolled + care_clinical_pathways ||--o{ care_patient_pathways : activated +``` + +Note: **no FK** from `care_pathway_templates` to `care_assessment_templates` — linkage by `template_code` string (KD-16). + +### New tables + +#### Catalog tenancy (summary) + +- Catalog: **no `owner_ref`**; platform-global; v1 no org clones. +- Runtime: always `owner_ref` + org (+ branch on assessments). + +#### `care_assessment_templates` + +| Column | Type | Notes | +|--------|------|-------| +| id | bigint PK | | +| uuid | uuid unique | public id | +| organization_id | FK nullable | **v1 always NULL** (system). Reserved for future forks. | +| code | string | e.g. `nihss`, `universal_intake` | +| name | string | | +| category | string | universal / disease / outcome / screening | +| description | text nullable | | +| version | unsigned int | default 1 | +| is_current | boolean | | +| scoring_strategy | string nullable | `sum_items`, `single_value`, `custom:HandlerName` | +| meta | json nullable | `capture_roles`, estimated_minutes, specialty, attribution | +| is_active | boolean | | +| timestamps | | | +| softDeletes | | | + +**Uniqueness (v1):** + +- Application invariant: for system rows (`organization_id IS NULL`), unique `(code, version)`. +- DB: unique index on `(code, version)` **valid in v1 because only system rows exist** and org clones are **out of scope**. +- **Future org forks (not v1):** use separate namespace — e.g. code prefix `org_{id}_nihss` **or** unique `(organization_id, code, version)` with sentinel `organization_id = 0` for system if MySQL NULL uniqueness is a problem. Documented so implementers do not invent conflicting clones mid-flight. +- Also enforce at most one row with `(code, is_current=true, organization_id null)` in service/seeder. + +#### `care_assessment_questions` + +| Column | Type | Notes | +|--------|------|-------| +| id | bigint PK | | +| template_id | FK cascade | | +| code | string | stable within template version | +| section | string nullable | | +| label | string | | +| help_text | text nullable | | +| answer_type | string | see answer types | +| options | json nullable | choices, min/max, unit | +| is_required | boolean | | +| sort_order | unsigned int | | +| score_key | string nullable | subscore bucket | +| validation | json nullable | min, max, regex | +| timestamps | | | +| unique | (template_id, code) | | + +#### `care_assessments` + +| Column | Type | Notes | +|--------|------|-------| +| id | bigint PK | | +| uuid | uuid unique | route key | +| owner_ref | string indexed | from patient | +| organization_id | FK | denormalized | +| branch_id | FK nullable | visit preferred else patient.branch_id | +| patient_id | FK cascade | | +| template_id | FK restrict | frozen version | +| consultation_id | FK nullOnDelete | | +| visit_id | FK nullOnDelete | | +| patient_pathway_id | FK nullOnDelete | | +| practitioner_id | FK nullable | | +| status | string | `draft`, `completed`, `cancelled` | +| assessed_at | timestamp nullable | clinical time (default now on complete) | +| completed_at | timestamp nullable | | +| completed_by | string nullable | | +| started_by | string nullable | | +| notes | text nullable | | +| timestamps | | | +| softDeletes | | | +| indexes | (patient_id, assessed_at), (consultation_id), (owner_ref, status), (template_id, status), (patient_id, template_id, status) | | + +#### `care_assessment_answers` + +| Column | Type | Notes | +|--------|------|-------| +| id | bigint PK | | +| owner_ref | string indexed | | +| assessment_id | FK cascade | | +| question_id | FK restrict | | +| value_text | text nullable | | +| value_number | decimal(12,4) nullable | | +| value_boolean | boolean nullable | | +| value_date | datetime nullable | | +| value_json | json nullable | | +| timestamps | | | +| unique | (assessment_id, question_id) | | + +No soft deletes on answers. + +#### `care_assessment_scores` (PR 4 migration) + +| Column | Type | Notes | +|--------|------|-------| +| id | bigint PK | | +| owner_ref | string indexed | | +| assessment_id | FK cascade unique | | +| template_code | string indexed | | +| total_score | decimal(12,4) nullable | | +| max_score | decimal(12,4) nullable | | +| subscores | json nullable | | +| severity_label | string nullable | | +| computed_at | timestamp | | +| timestamps | | | +| index | (owner_ref, template_code, computed_at) | | + +#### `care_clinical_pathways` + +| Column | Type | Notes | +|--------|------|-------| +| id | bigint PK | | +| uuid | uuid unique | | +| code | string unique | `stroke`, `diabetes`, … | +| name | string | | +| description | text nullable | | +| match_rules | json | icd_prefixes, keywords, exclude_keywords | +| is_active | boolean | | +| sort_order | unsigned int | | +| meta | json nullable | icon, color | +| timestamps | | | +| softDeletes | | | + +No `owner_ref` (catalog). + +#### `care_pathway_templates` + +| Column | Type | Notes | +|--------|------|-------| +| id | bigint PK | | +| pathway_id | FK cascade | | +| template_code | string | **only** — resolve `is_current` at start (KD-16) | +| is_required_on_activation | boolean | create draft when pathway activated | +| phase | string | `acute`, `follow_up`, `any` | +| sort_order | unsigned int | | +| unique | (pathway_id, template_code) | | + +**No `template_id` column.** + +#### `care_patient_pathways` + +| Column | Type | Notes | +|--------|------|-------| +| id | bigint PK | | +| uuid | uuid unique | | +| owner_ref | string indexed | | +| organization_id | FK | | +| patient_id | FK cascade | | +| pathway_id | FK restrict | | +| status | string | `active`, `resolved`, `inactive` | +| activated_at | timestamp | | +| activated_by | string nullable | | +| activation_consultation_id | FK nullable | | +| activation_diagnosis_text | string nullable | snapshot — **not** diagnosis FK | +| resolved_at | timestamp nullable | | +| notes | text nullable | | +| timestamps | | | +| softDeletes | | | +| index | (patient_id, status), (patient_id, pathway_id, status) | | + +### What stays typed (no change) + +- `care_patients`, emergency contacts, insurance, allergies, family history, conditions +- `care_vital_signs` +- `care_diagnoses` +- investigation catalog/results (string `value` on result values unchanged) +- prescriptions, bills + +### Migration strategy + +1. PR 1: templates, questions, assessments, answers (no scores). +2. PR 4: `care_assessment_scores` + scoring service. +3. PR 5: pathway tables. +4. Seed via `database/seeders/*` + JSON packs; wire into `DatabaseSeeder` / deploy runbook. +5. No backfill of historical consultations. +6. Rollback: feature flag off; drop tables only if zero production runtime rows. + +### Storage / load estimates + +| Assumption | Value | +|------------|-------| +| Active patients per org | 5,000–50,000 | +| Assessments per patient-year | 4–20 | +| Questions per instrument | 5–20 (NIHSS ~15) | +| Answer rows per assessment | ≈ question count | +| Growth | ~1M answer rows / large org / year | + +--- + +## Seed/JSON content pack schema + +**Path convention:** `database/data/assessments/{code}.v{version}.json` + +**Seeder:** `AssessmentTemplateSeeder` upserts by `(code, version)`; sets `is_current` per pack; loads questions replacing by template_id. `ClinicalPathwaySeeder` loads pathways + `pathway_templates` bindings. + +**Root object:** + +```json +{ + "template": { + "code": "mrs", + "name": "Modified Rankin Scale", + "category": "disease", + "version": 1, + "is_current": true, + "is_active": true, + "scoring_strategy": "single_value", + "description": "Global disability scale after stroke (0–6).", + "meta": { + "capture_roles": ["doctor"], + "specialty": "neurology", + "estimated_minutes": 2, + "attribution": "Modified Rankin Scale — use under applicable clinical/educational license; verify commercial redistribution rights before packaging." + } + }, + "questions": [ + { + "code": "mrs_score", + "section": "scale", + "label": "Modified Rankin Scale score", + "help_text": "Select the grade that best describes the patient.", + "answer_type": "score_item", + "is_required": true, + "sort_order": 1, + "score_key": "total", + "options": { + "min": 0, + "max": 6, + "choices": [ + {"code": "0", "label": "No symptoms at all", "score": 0}, + {"code": "1", "label": "No significant disability despite symptoms", "score": 1}, + {"code": "2", "label": "Slight disability", "score": 2}, + {"code": "3", "label": "Moderate disability", "score": 3}, + {"code": "4", "label": "Moderately severe disability", "score": 4}, + {"code": "5", "label": "Severe disability", "score": 5}, + {"code": "6", "label": "Dead", "score": 6} + ] + }, + "validation": {"min": 0, "max": 6} + } + ] +} +``` + +**Pathway pack** (`database/data/pathways/{code}.json`): + +```json +{ + "pathway": { + "code": "stroke", + "name": "Stroke", + "match_rules": { + "icd_prefixes": ["I60", "I61", "I62", "I63", "I64", "G45"], + "keywords": ["stroke", "cva", "tia", "transient ischaemic attack", "transient ischemic attack", "cerebrovascular", "hemiplegia", "ischaemic stroke", "ischemic stroke"], + "exclude_keywords": ["family history"] + }, + "is_active": true, + "sort_order": 10 + }, + "templates": [ + {"template_code": "nihss", "is_required_on_activation": true, "phase": "acute", "sort_order": 1}, + {"template_code": "mrs", "is_required_on_activation": true, "phase": "acute", "sort_order": 2} + ] +} +``` + +TIA diagnoses match this same `stroke` pathway (KD-19); subtype is recorded on assessment, not via a second pathway code. + +**Content-pack PR requirements:** + +1. JSON conforming to schema above. +2. Golden unit tests for scoring (e.g. mRS 3 → total 3; NIHSS fixture answers → known total). +3. Attribution/licensing note in `meta.attribution` (Open Question: commercial use of instrument IP — legal review before GA of each pack). +4. Seeder registration in `DatabaseSeeder` or documented `php artisan db:seed --class=...` in deploy runbook. + +--- + +## Universal template content (Layer 1 structured) + +Seed template `code = universal_intake` (illustrative sections): + +**Presenting complaint:** `chief_complaint` (textarea, required), `hpi`, `duration_value` + `duration_unit`, `pain_score` (scale 0–10), `current_symptoms`. + +**Social history:** `smoking_status`, `smoking_pack_years`, `alcohol_use`, `occupation`. + +**Functional status:** `mobility`, `communication`, `vision`, `hearing`, `feeding`, `continence`. + +**Baseline labs:** optional `baseline_labs_summary` only; prefer investigation results. + +Demographics/allergies/vitals remain on existing typed UI. + +**Dual narrative (KD-15):** consultation `symptoms` / `clinical_notes` remain the narrative source of truth for free-text clinical story. Universal intake does not replace or auto-sync them. Chart displays both when present. + +--- + +## Disease module content packs (Layer 3) + +### Stroke (`pathway.code = stroke`) + +**MVP (PR 6)** — production vertical slice: + +| Template code | Instruments | Required on activation | +|---------------|-------------|------------------------| +| `nihss` | NIH Stroke Scale items + total (`sum_items`) | yes | +| `mrs` | Modified Rankin Scale (`single_value`) | yes | + +**Follow-on (PR 6b)** — same pathway bindings, not blocking MVP: + +| Template code | Instruments | +|---------------|-------------| +| `barthel` | Barthel Index | +| `gcs` | Glasgow Coma Scale (E/V/M as score_items + sum or custom) | +| `stroke_swallow` | Swallow assessment | +| `stroke_clinical` | **Stroke subtype** (TIA / ischaemic / haemorrhagic / unspecified), TLKW, CT findings, thrombolysis, thrombectomy, limb strength, aphasia, dysphagia, spasticity, cognition, etc. | + +**Subtype (KD-19):** One pathway `stroke` for stroke **and** TIA. Subtype is a `single_choice` (or equivalent) field on `stroke_clinical` (and may be surfaced early on a lightweight field in MVP if needed). Do **not** create a separate TIA pathway. + +Match rules: ICD I60–I64 (and TIA codes such as G45.* as configured in seed); keywords include stroke, CVA, TIA, cerebrovascular, hemiplegia, ischaemic/ischemic stroke; `exclude_keywords` e.g. family history. + +### Diabetes (`pathway.code = diabetes`) — after stroke E2E + +Template `diabetes_core` (HbA1c entry optional, foot assessment, monofilament, eye exam, microalbuminuria, neuropathy score, hypoglycaemia episodes, diet adherence, insulin regimen). Optional read-only display of latest lab HbA1c (not hard dependency). + +### Deferred packs + +Heart Failure, CKD, Hypertension, Asthma, COPD, Dementia, Parkinson's, Cancer, Pregnancy, Orthopaedics — one PR per pack. + +--- + +## Layer 4 — Generic outcome measures + +Template `outcome_core`: quality_of_life, pain_score, functional_independence, medication_adherence, hospital_admissions_since_last, falls_count, readmissions_flag, patient_satisfaction. Prefer display of typed vitals for weight/BP rather than re-entry. + +Cadence: manual at follow-up; no scheduler in v1. + +--- + +## Alternatives Considered + +### Alternative A — Wide disease columns on patients/consultations + +**Rejected** — schema churn, sparse nulls, comorbidity nightmare. + +### Alternative B — Pure JSON document per consultation + +**Rejected** as primary store — weak validation/indexing/audit; JSON only for options, match rules, subscores. + +### Alternative C — Full FHIR Questionnaire + +**Deferred** — heavy for current monolith; export later if needed. + +### Alternative D — Org-scoped template builder UI first + +**Deferred** — seed system instruments first; org customization later with explicit uniqueness strategy (see templates uniqueness). + +### Alternative E — Consultation `structured_findings` JSON + typed scores table only (no pathway engine) + +Lighter intermediate: append parameter-style rows (similar spirit to investigation result values) and a scores table, without templates/pathways. + +| Pros | Cons | +|------|------| +| Faster first instrument | No versioned instruments, no comorbidity pathway activation, no reusable content packs, still invents ad hoc keys | + +**Rejected** — fails product goals for multi-specialty pathway overlays; full template engine is the target and not much more work once schema exists. + +### Chosen approach — Hybrid typed + metadata-driven assessments + +Balances hot-path query performance, instrument flexibility, and Care service/UI patterns. + +--- + +## Security & Privacy Considerations + +| Threat | Severity | Mitigation | +|--------|----------|------------| +| Cross-tenant assessment read/write | **Critical** | `owner_ref` on runtime; `authorizeOwner` + org check via patient/visit | +| Branch isolation bypass | **High** | KD-13: same `branchScope` as consultations; 404 cross-branch for branch-scoped members | +| Unauthorized specialty documentation | **High** | Route ability + `assertCaptureAllowed` (admin bypass; else `capture_roles`; nurses blocked on disease) | +| Catalog write abuse | **Medium** | No product write API for catalog in v1; seed/deploy only | +| PHI in audit logs | **Medium** | Action + subject ids + codes only; no full answers in metadata | +| Immutable clinical record tampering | **High** | KD-14: complete lock 422; soft-delete scoped queries | +| Template XSS | **Medium** | Seed labels; Blade escape | +| Diagnosis false pathway activation | **Medium** | Suggest-only; clinician confirms (KD-5) | +| Soft-deleted assessment answer leakage | **Low** | Always query answers through non-deleted assessment | + +No new public unauthenticated endpoints. + +--- + +## Observability + +### v1 (required) — logs + audit only + +Ladill Care has **no** StatsD/Prometheus metrics backend today. Do not block implementation on counters. + +- **Audit:** start/save/complete/cancel/activate/deactivate via `AuditLogger` + `config/care.php` actions. +- **Structured logs:** `Log::warning` / `Log::error` on scoring failures and capture denials with fields: `template_code`, `assessment_uuid`, `owner_ref` (or org id), `reason`. +- **Optional reporting:** later extend `ReportService` with assessment completion counts from audit or scores table (PR 8 optional). + +### Future (not v1) + +| Metric | Purpose | +|--------|---------| +| `care.assessments.completed` | Adoption | +| `care.pathways.activated` | Pathway usage | +| `care.scoring.failures` | Content pack bugs | + +Alerting: no dedicated channel in v1; scoring failures surface in application logs for on-call when log aggregation exists. + +--- + +## Rollout Plan + +### Feature flags (`CareFeatures`, not PlanService) + +`PlanService::hasFeature` is for **plan entitlements** (lab, pharmacy, billing). Rollout flags are separate. + +**Storage:** `care_organizations.settings` JSON: + +```json +{ + "plan": "pro", + "rollout": { + "assessments_engine": true, + "pathway_suggestions": true, + "assessment_required_on_complete": false + } +} +``` + +**Helper:** `App\Services\Care\CareFeatures::enabled(Organization $org, string $flag): bool` reading `settings.rollout.{flag}`, default **false** until explicitly enabled (internal tenants first). + +**Ships in PR 2** so UI can gate. Do not nest under `settings.features` (collides conceptually with plan feature lists in `config/care.php`). + +### Plan packaging (KD-18 — final) + +| Capability | Plan gate | +|------------|-----------| +| Core assessment engine, universal intake, pathway activation, disease pack **capture** (Stroke, Diabetes, …) | **Free** (all plans) — not Enterprise-gated | +| Patient-level outcome history / simple per-patient score list (basic chart) | **Free** | +| **Advanced analytics** — org-level instrument trends, multi-patient assessment dashboards, advanced outcome analytics | **Enterprise** (`PlanService::hasFeature` / enterprise plan features; distinct from rollout flags) | + +Rollout flags (`CareFeatures`) control gradual enablement; plan packaging controls Enterprise-only analytics surfaces. Disease content packs are **not** sold as Enterprise-only for capture. + +### Stages + +1. **Internal** — migrate + seed universal; `rollout.assessments_engine` for staging. +2. **Pilot** — pathways + stroke MVP (NIHSS/mRS). +3. **GA** — default rollout on for new orgs; content pack runbook. +4. **Outcomes / analytics** — basic free; advanced org trends Enterprise-gated. + +### Rollback + +- Disable rollout flag → hide UI; data retained. +- Reverse migration only if zero production assessments. +- Content pack errors: `is_active = false` on template/pathway. + +### Performance safeguards + +- Eager-load questions with template. +- Paginate assessment history. +- Use scores table for trends; no answer-table scans for lists. + +--- + +## Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Free-text diagnoses noisy matches | Medium | Ranked algorithm + exclude_keywords + explicit picker + match reasons; tests for false positives | +| Clinician form fatigue | Medium | Required templates only acute MVP (NIHSS/mRS); more instruments optional later | +| Scoring bugs | High | Golden tests per content pack; frozen template versions | +| Dual HPI narrative | Medium | KD-15 explicit dual display; no auto-copy | +| Instrument licensing | Medium | `meta.attribution`; legal review before GA per pack | +| Dual BP/weight sources | Low | Typed vitals preferred in UI | +| Race on pathway activate | Medium | Transaction + lockForUpdate; idempotent activate | +| Large stroke content PR | Medium | Split MVP (PR 6) vs 6b | + +--- + +## Resolved decisions + +Product decisions closed for implementation (do not re-litigate without a new design revision): + +| Topic | Decision | Anchored in | +|-------|----------|-------------| +| **Plan gating** | Engine + universal intake + disease pack **capture** free on all plans. **Advanced analytics / org-level trends** → Enterprise. | KD-18, Rollout → Plan packaging | +| **TIA vs Stroke** | **One** `stroke` pathway. TIA / ischaemic / haemorrhagic are **subtype** fields on stroke assessments (e.g. `stroke_clinical`), not separate pathways. Match rules include TIA. | KD-19, Layer 3 Stroke | +| **Chronic conditions sync** | **No** auto-add. `care_patient_pathways` and `care_patient_conditions` remain independent lists. | KD-20 | +| **Multi-branch chart** | Match consultations / `branchScope`. | KD-13 | +| **Complete immutability (v1)** | Hard lock; no amend in v1. | KD-14 | +| **Dual HPI narrative** | Free-text symptoms primary; universal intake optional overlay; no auto-copy. | KD-15 | +| **Admin capture** | `hospital_admin` / `super_admin` always pass `assertCaptureAllowed`. | KD-9 | + +--- + +## Open Questions + +Still open (not blocking PR 1 engine work): + +1. **Amend completed assessments (post-v1)** — versioned amendments with reason vs permanent lock after v1? +2. **Lab value pull-through** — how tightly to couple diabetes HbA1c display to `care_investigation_results` in the diabetes pack? +3. **Nurse NIHSS** — org-configurable `capture_roles` later? (v1: disease `["doctor"]`; hospital_admin/super_admin always allowed.) +4. **Internationalization** — English-only first packs? +5. **Clinical instrument licensing** — commercial redistribution rights for NIHSS/mRS/etc. before GA of each pack? + +--- + +## References + +### Internal code + +| Path | Relevance | +|------|-----------| +| `database/migrations/2026_07_01_100000_create_care_patient_tables.php` | Patient, allergies, conditions, family, insurance | +| `database/migrations/2026_07_02_100000_create_care_clinical_tables.php` | Visits, consultations, vitals, diagnoses | +| `database/migrations/2026_07_03_100000_create_care_lab_and_prescription_tables.php` | Result values: **string** `value` only | +| `app/Models/Consultation.php` | Status draft/completed, relations | +| `app/Models/Diagnosis.php` | Pathway matching input | +| `app/Models/VitalSign.php` | Typed vitals retained | +| `app/Models/Concerns/BelongsToOwner.php` | Runtime tenant scope | +| `app/Services/Care/ConsultationService.php` | save/complete/syncDiagnoses | +| `app/Services/Care/PatientService.php` | Registration remains universal | +| `app/Services/Care/CarePermissions.php` | Role abilities (extend) | +| `app/Services/Care/PlanService.php` | Plan entitlements — **not** rollout flags | +| `app/Services/Care/AuditLogger.php` | Clinical audit | +| `app/Http/Controllers/Care/ConsultationController.php` | Web integration; vitals vs manage split pattern | +| `app/Http/Controllers/Care/Concerns/ScopesToAccount.php` | Authz helpers | +| `resources/views/care/consultations/show.blade.php` | Primary UI surface | +| `resources/views/care/patients/_form.blade.php` | Demographics (no specialty) | +| `config/care.php` | Enums, audit actions, plans | +| `routes/web.php` | Route registration | +| `tests/Feature/CarePatientTest.php`, `CareLabTest.php` | Test style to extend | +| `database/seeders/` | Currently empty of assessment packs — greenfield | + +### External clinical instruments + +- NIH Stroke Scale (NIHSS), Modified Rankin Scale (mRS), Barthel, GCS, NYHA, CAT, mMRC, Hoehn & Yahr, UPDRS, MMSE/MoCA — with licensing review before commercial packaging. + +--- + +## PR Plan + +Incremental, independently reviewable PRs. Merge order as listed. + +### Milestones + +| Milestone | After PRs | Demoable outcome | +|-----------|-----------|------------------| +| **M1 — Universal intake** | PR 1–3 | Register patient; complete structured universal intake on consultation; free-text symptoms still work | +| **M2 — Pathways + stroke E2E** | PR 4–6 | Save diagnoses → suggestions → activate stroke → complete NIHSS/mRS with scores | +| **M3 — Breadth** | PR 6b–8 | Extra stroke instruments, diabetes, outcomes trends | +| **M4 — API** | PR 9 | Mobile/API parity | + +### PR 1 — Assessment engine schema + domain models + +- **Title:** `feat(assessments): add template-driven assessment tables and models` +- **Depends on:** none +- **Files:** migration for templates, questions, assessments, answers (**no** scores table); catalog models without `BelongsToOwner`; runtime models with `BelongsToOwner`; minimal relation/uuid tests +- **Description:** Additive schema only. Establishes catalog tenancy contract and uniqueness `(code, version)` for system templates. + +### PR 2 — AssessmentService + permissions + CareFeatures + web CRUD + +- **Title:** `feat(assessments): service layer, permissions, rollout flags, and capture UI` +- **Depends on:** PR 1 +- **Files:** + - `AssessmentService` (normalize invariants, capture_roles, draft idempotency, cancel, complete lock) + - `CareFeatures` + `settings.rollout.*` + - `CarePermissions`: `assessments.view`, `assessments.capture`, `assessments.manage` + - `AssessmentController`, routes, Blade forms + - `config/care.php` statuses + audit actions + - Feature tests: nurse universal yes / NIHSS no; doctor + hospital_admin NIHSS yes; tenant 404; public UUID body fields +- **Description:** Full capture lifecycle gated by rollout flag. No pathway tables yet. + +### PR 3 — Universal intake seed + consultation entry + +- **Title:** `feat(assessments): seed universal intake and link from consultation` +- **Depends on:** PR 2 +- **Files:** `database/data/assessments/universal_intake.v1.json`, `AssessmentTemplateSeeder`, wire `DatabaseSeeder` / deploy note; consultation show section; patient timeline; dual narrative UI (symptoms + intake); tests +- **Description:** M1 complete. Layer 1 structured overlay without patient column churn. + +### PR 4 — Scoring materialization + +- **Title:** `feat(assessments): scoring service and care_assessment_scores` +- **Depends on:** PR 2 +- **Files:** **only** migration for `care_assessment_scores` (not in PR 1); `AssessmentScore`; `ScoringService` + `sum_items` / `single_value`; unit tests; complete path integration +- **Description:** Materialize scores on complete. Required before specialty packs. + +### PR 5 — Clinical pathway registry + patient activation + +- **Title:** `feat(pathways): clinical pathway registry and patient activation` +- **Depends on:** PR 2 (PR 3 recommended for realistic consultation UX) +- **Files:** pathway migrations (`template_code` only on bindings); `PathwayService`, `PathwayMatcher` (full algorithm + tests: I63.9, ischaemic stroke, history unclear, exclude family history); `pathways.manage`; deactivate POST (not DELETE); suggestions endpoint on **persisted** diagnoses; consultation UI suggestions + “Save diagnoses & suggest”; activate creates required drafts; `lockForUpdate` uniqueness; `CarePathwayTest` +- **Description:** Layer 2. Explicit activation; comorbidity-safe. + +### PR 6 — Stroke MVP content pack (NIHSS + mRS) + +- **Title:** `feat(pathways): stroke MVP content pack (NIHSS, mRS)` +- **Depends on:** PR 4, PR 5 (PR 3 recommended) +- **Files:** `nihss.v1.json`, `mrs.v1.json`, stroke pathway JSON with required bindings; golden scoring tests; consultation instruments list when stroke active +- **Description:** **M2 vertical slice.** Do not include Barthel/GCS/swallow/clinical in this PR. + +### PR 6b — Stroke extended instruments + +- **Title:** `feat(pathways): stroke extended instruments (Barthel, GCS, swallow, clinical)` +- **Depends on:** PR 6 +- **Files:** remaining stroke templates + pathway bindings (optional/required as product chooses); scoring tests +- **Description:** Completes full stroke module without blocking E2E milestone. + +### PR 7 — Diabetes content pack + +- **Title:** `feat(pathways): diabetes assessment content pack` +- **Depends on:** PR 6 (stroke E2E proven), PR 4, PR 5 +- **Files:** `diabetes_core` seed + pathway; optional lab HbA1c display; comorbidity tests (stroke + diabetes) +- **Description:** Second module after stroke path is stable. + +### PR 8 — Generic outcomes + patient trends (+ optional ReportService) + +- **Title:** `feat(assessments): outcome measures template and patient trend view` +- **Depends on:** PR 4, PR 2 +- **Files:** `outcome_core` seed; per-patient outcome history (free); optional org-level `ReportService` analytics gated Enterprise (KD-18) +- **Description:** Layer 4. Basic patient chart free; advanced multi-patient trends Enterprise. + +### PR 9 — API parity + OpenAPI + +- **Title:** `feat(api): assessment and pathway endpoints` +- **Depends on:** PR 5, PR 2 +- **Files:** API controllers; `routes/api.php`; `docs/openapi/care.yaml`; feature tests paralleling `CareLabTest` tenant/branch isolation +- **Description:** Mobile/clients parity. + +### PR 10 — Additional content packs (series) + +- **Title:** `feat(pathways): {copd|heart_failure|dementia|…} content pack` +- **Depends on:** PR 5, PR 4 +- **Description:** One PR per pack; schema-stable. + +### Out of scope for this PR sequence + +- Org template builder / org forks +- FHIR Questionnaire export +- Hard-block consultation complete on missing assessments +- Automated outcome reminders +- Full ICD-10 terminology service +- Metrics backend integration +- Org match_rules overrides + +--- + +## Success criteria + +1. New patient can be registered and consulted **without** selecting a specialty. +2. Clinician can complete a **universal intake** assessment linked to a consultation (M1). +3. After **saving** diagnoses, **pathway suggestions** appear with match reasons; activation supports **multiple** pathways (M2). +4. Stroke pathway exposes **NIHSS + mRS**; completion stores answers + **materialized score** (M2). +5. Adding a new instrument requires **seed JSON + seeder only**, no clinical column migration. +6. Assessment mutations appear in **audit log**; tenant + branch isolation enforced in tests. +7. Typed vitals/diagnoses/labs and free-text symptoms continue to work unchanged on `consultations/show`. +8. Nurse can capture universal intake; nurse **cannot** start NIHSS; doctor and **hospital_admin** can. +9. Completed assessment PUT returns **422**; cancel works on drafts only. +10. Web/API request bodies reference consultations/visits by **UUID**, resolved server-side to FKs. diff --git a/docs/openapi/care.yaml b/docs/openapi/care.yaml index 35eefac..53bf3ec 100644 --- a/docs/openapi/care.yaml +++ b/docs/openapi/care.yaml @@ -1,8 +1,11 @@ openapi: 3.1.0 info: title: Ladill Care API - version: 1.0.0 - description: Healthcare management API at care.ladill.com + version: 1.1.0 + description: | + Healthcare management API at care.ladill.com. + Assessment and pathway endpoints require organization `settings.rollout.assessments_engine = true`. + Public IDs are UUIDs (never internal integer FKs). servers: - url: https://care.ladill.com/api/v1 paths: @@ -16,6 +19,13 @@ paths: summary: List patients post: summary: Register patient + /patients/{patient}: + get: + summary: Patient dashboard + put: + summary: Update patient + delete: + summary: Archive patient /appointments: get: summary: List appointments @@ -29,3 +39,250 @@ paths: summary: List pharmacy inventory post: summary: Add drug + + /assessment-templates: + get: + summary: List current system assessment templates + tags: [Assessments] + security: [{ sanctum: [] }] + responses: + '200': + description: Template catalog with questions + '404': + description: Assessments engine not enabled + + /patients/{patient}/assessments: + parameters: + - $ref: '#/components/parameters/PatientUuid' + get: + summary: List assessments for a patient + tags: [Assessments] + security: [{ sanctum: [] }] + parameters: + - name: status + in: query + schema: { type: string, enum: [draft, completed, cancelled] } + - name: template_code + in: query + schema: { type: string } + - name: category + in: query + schema: { type: string } + responses: + '200': + description: Paginated assessments + post: + summary: Start assessment (idempotent draft) + tags: [Assessments] + security: [{ sanctum: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [template_code] + properties: + template_code: { type: string, example: nihss } + consultation_uuid: { type: string, format: uuid, nullable: true } + visit_uuid: { type: string, format: uuid, nullable: true } + responses: + '200': + description: Existing draft returned + '201': + description: New draft created + '403': + description: Capture not allowed for role/template + + /consultations/{consultation}/assessments: + post: + summary: Start assessment linked to consultation + tags: [Assessments] + security: [{ sanctum: [] }] + parameters: + - $ref: '#/components/parameters/ConsultationUuid' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [template_code] + properties: + template_code: { type: string } + responses: + '200': + description: Existing draft + '201': + description: Created + + /assessments/{assessment}: + parameters: + - $ref: '#/components/parameters/AssessmentUuid' + get: + summary: Get assessment with answers and score + tags: [Assessments] + security: [{ sanctum: [] }] + responses: + '200': + description: Assessment detail + put: + summary: Save draft answers (keyed by question code) + tags: [Assessments] + security: [{ sanctum: [] }] + requestBody: + content: + application/json: + schema: + type: object + properties: + answers: + type: object + additionalProperties: true + example: { chief_complaint: "Headache", pain_score: 4 } + notes: { type: string, nullable: true } + responses: + '200': + description: Updated + '422': + description: Not draft or validation error + + /assessments/{assessment}/complete: + post: + summary: Complete assessment (validates required; materializes score if strategy set) + tags: [Assessments] + security: [{ sanctum: [] }] + parameters: + - $ref: '#/components/parameters/AssessmentUuid' + responses: + '200': + description: Completed with optional score + '422': + description: Missing required answers or already completed + + /assessments/{assessment}/cancel: + post: + summary: Cancel draft assessment + tags: [Assessments] + security: [{ sanctum: [] }] + parameters: + - $ref: '#/components/parameters/AssessmentUuid' + responses: + '200': + description: Cancelled + + /assessments/{assessment}/fhir: + get: + summary: Export assessment as FHIR R4 Bundle (Questionnaire + QuestionnaireResponse) + tags: [Assessments] + security: [{ sanctum: [] }] + parameters: + - $ref: '#/components/parameters/AssessmentUuid' + responses: + '200': + description: application/fhir+json Bundle + content: + application/fhir+json: + schema: + type: object + + /pathways: + get: + summary: List active clinical pathway catalog + tags: [Pathways] + security: [{ sanctum: [] }] + responses: + '200': + description: Pathway catalog with template bindings + + /patients/{patient}/pathways: + parameters: + - $ref: '#/components/parameters/PatientUuid' + get: + summary: Active and historical patient pathways + tags: [Pathways] + security: [{ sanctum: [] }] + responses: + '200': + description: Active + history + post: + summary: Activate pathway (creates required draft assessments) + tags: [Pathways] + security: [{ sanctum: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [pathway_code] + properties: + pathway_code: { type: string, example: stroke } + consultation_uuid: { type: string, format: uuid, nullable: true } + activation_diagnosis_text: { type: string, nullable: true } + responses: + '201': + description: Activated (idempotent if already active) + + /patients/{patient}/pathways/{patientPathway}/deactivate: + post: + summary: Deactivate active pathway + tags: [Pathways] + security: [{ sanctum: [] }] + parameters: + - $ref: '#/components/parameters/PatientUuid' + - name: patientPathway + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: Deactivated + + /consultations/{consultation}/pathway-suggestions: + get: + summary: Suggest pathways from persisted diagnoses only + tags: [Pathways] + security: [{ sanctum: [] }] + parameters: + - $ref: '#/components/parameters/ConsultationUuid' + responses: + '200': + description: Ranked suggestions with match reasons + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + pathway_code: { type: string } + pathway_name: { type: string } + rank: { type: integer } + match_reason: { type: string } + already_active: { type: boolean } + +components: + securitySchemes: + sanctum: + type: http + scheme: bearer + parameters: + PatientUuid: + name: patient + in: path + required: true + schema: { type: string, format: uuid } + ConsultationUuid: + name: consultation + in: path + required: true + schema: { type: string, format: uuid } + AssessmentUuid: + name: assessment + in: path + required: true + schema: { type: string, format: uuid } diff --git a/resources/views/care/assessments/_form.blade.php b/resources/views/care/assessments/_form.blade.php new file mode 100644 index 0000000..dd3818c --- /dev/null +++ b/resources/views/care/assessments/_form.blade.php @@ -0,0 +1,122 @@ +@php + /** @var \App\Models\Assessment $assessment */ + /** @var \Illuminate\Support\Collection $answersByCode */ + $questions = $assessment->template->questions; + $grouped = $questions->groupBy(fn ($q) => $q->section ?: 'General'); +@endphp + +
+ @foreach ($grouped as $section => $sectionQuestions) +
+

{{ $section }}

+
+ @foreach ($sectionQuestions as $question) + @php + $name = 'answers['.$question->code.']'; + $old = old('answers.'.$question->code, $answersByCode[$question->code] ?? null); + $options = $question->options ?? []; + $choices = $options['choices'] ?? []; + @endphp +
+ + @if ($question->help_text) +

{{ $question->help_text }}

+ @endif + + @switch($question->answer_type) + @case('textarea') + + @break + + @case('number') + @case('integer') + @case('scale') + answer_type === 'integer') step="1" @else step="any" @endif + class="mt-1 w-full rounded-lg border-slate-300 text-sm" + > + @break + + @case('boolean') + + @break + + @case('date') + + @break + + @case('datetime') + + @break + + @case('single_choice') + @case('score_item') + + @break + + @case('multi_choice') + @php $selected = is_array($old) ? $old : []; @endphp +
+ @foreach ($choices as $choice) + @php $code = (string) ($choice['code'] ?? ''); @endphp + + @endforeach +
+ @break + + @case('calculated') +

{{ $old ?? '—' }}

+ @break + + @default + + @endswitch + + @error('answers.'.$question->code) +

{{ $message }}

+ @enderror +
+ @endforeach +
+
+ @endforeach + +
+ + +
+
diff --git a/resources/views/care/assessments/create.blade.php b/resources/views/care/assessments/create.blade.php new file mode 100644 index 0000000..b7eb6a3 --- /dev/null +++ b/resources/views/care/assessments/create.blade.php @@ -0,0 +1,46 @@ + +
+

Start assessment

+

{{ $patient->fullName() }}

+

+ {{ $patient->patient_number }} +

+
+ +
+ @if ($templates->isEmpty()) +

No capturable assessment templates are available for your role. Ensure content packs are seeded and the assessments engine is enabled.

+ @else +
+ @csrf + @if ($consultationUuid) + + @endif + @if ($visitUuid) + + @endif + +
+ + + @error('template_code') +

{{ $message }}

+ @enderror +
+ +
+ + Cancel +
+
+ @endif +
+
diff --git a/resources/views/care/assessments/index.blade.php b/resources/views/care/assessments/index.blade.php new file mode 100644 index 0000000..f873ecd --- /dev/null +++ b/resources/views/care/assessments/index.blade.php @@ -0,0 +1,68 @@ + +
+
+

Clinical assessments

+

{{ $patient->fullName() }}

+

+ {{ $patient->patient_number }} +

+
+ @if ($canCapture) + Start assessment + @endif +
+ +
+ + + + +
+ +
+ + + + + + + + + + + @forelse ($assessments as $assessment) + + + + + + + @empty + + + + @endforelse + +
TemplateStatusAssessed
+

{{ $assessment->template->name }}

+

{{ $assessment->template->code }} · v{{ $assessment->template->version }}

+
{{ $statuses[$assessment->status] ?? $assessment->status }} + {{ $assessment->assessed_at?->format('d M Y H:i') ?? $assessment->created_at?->format('d M Y H:i') ?? '—' }} + + Open +
No assessments yet.
+ @if ($assessments->hasPages()) +
{{ $assessments->withQueryString()->links() }}
+ @endif +
+
diff --git a/resources/views/care/assessments/show.blade.php b/resources/views/care/assessments/show.blade.php new file mode 100644 index 0000000..c8feb6c --- /dev/null +++ b/resources/views/care/assessments/show.blade.php @@ -0,0 +1,116 @@ + +
+
+

+ {{ $statuses[$assessment->status] ?? $assessment->status }} +

+

{{ $assessment->template->name }}

+

+ {{ $assessment->patient->fullName() }} + · {{ $assessment->template->code }} v{{ $assessment->template->version }} + @if ($assessment->consultation) + · Consultation + @endif +

+
+ +
+ + @if ($assessment->score) +
+

Score

+
+
+
Total
+
+ {{ $assessment->score->total_score }} + @if ($assessment->score->max_score !== null) + / {{ $assessment->score->max_score }} + @endif +
+
+ @if ($assessment->score->severity_label) +
+
Severity
+
{{ $assessment->score->severity_label }}
+
+ @endif + @if ($assessment->score->subscores) +
+
Subscores
+
+ @foreach ($assessment->score->subscores as $key => $val) + {{ $key }}: {{ $val }} + @endforeach +
+
+ @endif +
+
+ @endif + + @if ($canEdit) +
+
+ @csrf + @method('PUT') + @include('care.assessments._form', ['assessment' => $assessment, 'answersByCode' => $answersByCode]) +
+ + +
+
+ +
+ + + + + +
+

Required fields are validated when you complete the assessment.

+
+ @else +
+

Answers

+
+ @foreach ($assessment->template->questions as $question) + @php $value = $answersByCode[$question->code] ?? null; @endphp +
+
{{ $question->label }}
+
+ @if (is_array($value)) + {{ implode(', ', $value) }} + @elseif (is_bool($value)) + {{ $value ? 'Yes' : 'No' }} + @elseif ($value instanceof \Carbon\Carbon) + {{ $value->format($question->answer_type === 'date' ? 'd M Y' : 'd M Y H:i') }} + @else + {{ $value ?? '—' }} + @endif +
+
+ @endforeach +
+ @if ($assessment->notes) +

Notes: {{ $assessment->notes }}

+ @endif +
+ @endif +
diff --git a/resources/views/care/consultations/show.blade.php b/resources/views/care/consultations/show.blade.php index e1577f6..ed4488f 100644 --- a/resources/views/care/consultations/show.blade.php +++ b/resources/views/care/consultations/show.blade.php @@ -88,6 +88,199 @@ @endif + @if ($canViewAssessments ?? false) + {{-- Layer 1: universal intake (structured overlay; does not replace free-text symptoms) --}} + @if ($universalTemplate) +
+
+
+

Universal intake

+

+ Structured Layer 1 assessment (presenting complaint, social history, function). + Free-text Symptoms and Clinical notes below remain the narrative source of truth — they are not auto-copied either way. +

+
+ @if ($universalAssessment) + + {{ $universalAssessment->isDraft() ? 'Continue intake' : 'View intake' }} + + @elseif (($canCaptureUniversal ?? false) && ! $isCompleted) +
+ @csrf + + +
+ @endif +
+ + @if ($universalAssessment) +

+ Status: + {{ $assessmentStatuses[$universalAssessment->status] ?? $universalAssessment->status }} + @if ($universalAssessment->consultation_id !== $consultation->id) + (from another visit — open to review) + @endif +

+ @php + $cc = $universalAssessment->answers->first(fn ($a) => $a->question?->code === 'chief_complaint'); + $pain = $universalAssessment->answers->first(fn ($a) => $a->question?->code === 'pain_score'); + @endphp + @if ($cc || $pain) +
+ @if ($cc) +
+
Chief complaint
+
{{ \Illuminate\Support\Str::limit((string) $cc->authoritativeValue(), 160) }}
+
+ @endif + @if ($pain) +
+
Pain score
+
{{ $pain->authoritativeValue() ?? '—' }}
+
+ @endif +
+ @endif + @elseif (! ($canCaptureUniversal ?? false)) +

No universal intake for this patient yet.

+ @endif +
+ @endif + + {{-- Layer 2: pathways (suggest from persisted diagnoses; clinician confirms) --}} +
+
+

Clinical pathways

+ Manage pathways +
+ + @if (($activePathways ?? collect())->isNotEmpty()) +
+ @foreach ($activePathways as $pp) + + {{ $pp->pathway->name }} + + @endforeach +
+ @endif + + @if (($pathwayInstruments ?? collect())->isNotEmpty()) +
+
+ Pathway instruments +
+
    + @foreach ($pathwayInstruments as $instrument) +
  • +
    + {{ $instrument['template_name'] }} + + · {{ $instrument['pathway_name'] }} + · {{ $instrument['template_code'] }} + @if ($instrument['required']) · required @endif + + @if ($instrument['assessment']?->score) + + score {{ $instrument['assessment']->score->total_score }}@if($instrument['assessment']->score->max_score !== null)/{{ $instrument['assessment']->score->max_score }}@endif + + @endif +
    +
    + @if ($instrument['pack_missing']) + Content pack not seeded + @elseif ($instrument['assessment']) + {{ $assessmentStatuses[$instrument['assessment']->status] ?? $instrument['assessment']->status }} + + {{ $instrument['assessment']->isDraft() ? 'Continue' : 'View' }} + + @elseif (($canCaptureAssessment ?? false) && ! $isCompleted) +
    + @csrf + + +
    + @else + Not started + @endif +
    +
  • + @endforeach +
+
+ @endif + + @if (($pathwaySuggestions ?? collect())->isNotEmpty()) +
    + @foreach ($pathwaySuggestions as $suggestion) +
  • +
    + {{ $suggestion['pathway_name'] }} + + · {{ $suggestion['match_reason'] }} + @if ($suggestion['already_active']) · already active @endif + +
    + @if (($canManagePathways ?? false) && ! $suggestion['already_active'] && ! $isCompleted) +
    + @csrf + + + +
    + @endif +
  • + @endforeach +
+ @else +

+ @if ($consultation->diagnoses->isEmpty()) + Save diagnoses to see pathway suggestions. + @else + No pathway matches for saved diagnoses. + @endif +

+ @endif +
+ +
+
+

Clinical assessments

+ Patient assessments +
+ + @if (($consultationAssessments ?? collect())->isNotEmpty()) +
    + @foreach ($consultationAssessments as $item) +
  • + + {{ $item->template->name }} + + · {{ ($assessmentStatuses[$item->status] ?? $item->status) }} +
  • + @endforeach +
+ @else +

No assessments linked to this consultation yet.

+ @endif + + @if (($canCaptureAssessment ?? false) && ! $isCompleted && ($startableTemplates ?? collect())->isNotEmpty()) +
+ @csrf +
+ + +
+ +
+ @endif +
+ @endif + @if (! $isCompleted && ($canManage || $canVitals))
@csrf @method('PUT') @@ -135,7 +328,12 @@ @if ($canManage)
-

Clinical notes

+

Clinical notes (narrative)

+ @if ($canViewAssessments ?? false) +

+ Narrative source of truth for this encounter. Structured universal intake is a separate overlay above and is not auto-synced with these fields. +

+ @endif
@@ -188,8 +386,13 @@
@endif -
+
+ @if ($canManage && ($canViewAssessments ?? false)) + + @endif Back to queue
@@ -211,7 +414,7 @@ @if ($consultation->symptoms || $consultation->clinical_notes)
-

Clinical notes

+

Clinical notes (narrative)

@if ($consultation->symptoms)

Symptoms: {{ $consultation->symptoms }}

@endif @@ -221,6 +424,34 @@
@endif + @if (($canViewAssessments ?? false) && ($universalAssessment ?? null)?->isCompleted()) +
+
+

Universal intake (structured)

+ Open +
+
+ @foreach ($universalAssessment->answers->sortBy(fn ($a) => $a->question?->sort_order ?? 0) as $answer) + @if ($answer->question) +
+
{{ $answer->question->label }}
+
+ @php $v = $answer->authoritativeValue(); @endphp + @if (is_array($v)) + {{ implode(', ', $v) }} + @elseif (is_bool($v)) + {{ $v ? 'Yes' : 'No' }} + @else + {{ $v ?? '—' }} + @endif +
+
+ @endif + @endforeach +
+
+ @endif + @if ($consultation->diagnoses->isNotEmpty())

Diagnoses

diff --git a/resources/views/care/outcomes/index.blade.php b/resources/views/care/outcomes/index.blade.php new file mode 100644 index 0000000..5cdb57a --- /dev/null +++ b/resources/views/care/outcomes/index.blade.php @@ -0,0 +1,153 @@ + +
+
+

Longitudinal outcomes

+

{{ $patient->fullName() }}

+

+ {{ $patient->patient_number }} + · + Assessments + · + Pathways +

+
+ @if ($canCapture) +
+ @csrf + +
+ @endif +
+ +

+ Patient-level outcome history is available on all plans. Weight and blood pressure come from consultation vitals (typed records), not the outcome form. + @unless ($canOrgAnalytics) + Org-wide multi-patient analytics require Enterprise. + @endunless +

+ +
+
+

Quality of life trend

+ @if (empty($qolSeries)) +

No QoL scores yet.

+ @else +
    + @foreach ($qolSeries as $point) +
  • + {{ $point['date'] }} + {{ $point['value'] }} +
  • + @endforeach +
+ @endif +
+ +
+

Pain score trend

+ @if (empty($painSeries)) +

No pain scores yet.

+ @else +
    + @foreach ($painSeries as $point) +
  • + {{ $point['date'] }} + {{ $point['value'] }} +
  • + @endforeach +
+ @endif +
+
+ +
+
+ Outcome assessments +
+ + + + + + + + + + + + + @forelse ($outcomeSeries as $row) + @php $a = $row['answers']; @endphp + + + + + + + + + @empty + + + + @endforelse + +
DateQoLPainFallsAdmissions
{{ $row['assessed_at']?->format('d M Y') ?? '—' }}{{ $a['quality_of_life'] ?? '—' }}{{ $a['pain_score'] ?? '—' }}{{ $a['falls_count'] ?? '—' }}{{ $a['hospital_admissions_since_last'] ?? '—' }} + View +
No completed outcome assessments yet.
+
+ +
+
+ Instrument scores +
+ + + + + + + + + + + + @forelse ($scores as $score) + + + + + + + + @empty + + + + @endforelse + +
InstrumentTotalMaxDate
{{ $score->template_code }}{{ $score->total_score }}{{ $score->max_score ?? '—' }}{{ $score->computed_at?->format('d M Y H:i') }} + @if ($score->assessment) + Open + @endif +
No scored instruments yet (e.g. NIHSS, mRS, Barthel, GCS).
+
+ +
+

Recent vitals (typed)

+ @if ($vitals->isEmpty()) +

No vitals recorded on consultations.

+ @else +
    + @foreach ($vitals as $v) +
  • + {{ $v->recorded_at?->format('d M Y H:i') }} + · BP {{ $v->bp_systolic }}/{{ $v->bp_diastolic }} + · Wt {{ $v->weight_kg ?? '—' }} kg + · SpO₂ {{ $v->spo2 ? $v->spo2.'%' : '—' }} +
  • + @endforeach +
+ @endif +
+
diff --git a/resources/views/care/pathways/index.blade.php b/resources/views/care/pathways/index.blade.php new file mode 100644 index 0000000..b1030dc --- /dev/null +++ b/resources/views/care/pathways/index.blade.php @@ -0,0 +1,89 @@ + +
+
+

Clinical pathways

+

{{ $patient->fullName() }}

+

+ {{ $patient->patient_number }} + · + Assessments +

+
+
+ +
+

Active pathways

+ @if ($active->isEmpty()) +

No active pathways.

+ @else +
    + @foreach ($active as $row) +
  • +
    +

    {{ $row->pathway->name }}

    +

    + Activated {{ $row->activated_at?->format('d M Y H:i') }} + @if ($row->activation_diagnosis_text) + · {{ \Illuminate\Support\Str::limit($row->activation_diagnosis_text, 80) }} + @endif +

    +
    + @if ($canManage) +
    + @csrf + +
    + @endif +
  • + @endforeach +
+ @endif +
+ + @if ($canManage && $catalog->isNotEmpty()) +
+

Activate pathway

+
+ @csrf +
+ + +
+ +
+

Required disease assessments are drafted when their content packs are seeded.

+
+ @endif + +
+
History
+ + + + + + + + + + @forelse ($history as $row) + + + + + + @empty + + @endforelse + +
PathwayStatusActivated
{{ $row->pathway?->name ?? '—' }}{{ $statuses[$row->status] ?? $row->status }}{{ $row->activated_at?->format('d M Y H:i') ?? '—' }}
No pathway history.
+ @if ($history->hasPages()) +
{{ $history->links() }}
+ @endif +
+
diff --git a/resources/views/care/patients/show.blade.php b/resources/views/care/patients/show.blade.php index 3189530..b982e10 100644 --- a/resources/views/care/patients/show.blade.php +++ b/resources/views/care/patients/show.blade.php @@ -137,6 +137,51 @@

No prescriptions yet

@endforelse
+ @if ($canViewAssessments ?? false) +
+
+

Assessments

+ View all +
+ @if ($latestUniversalIntake ?? null) + @php + $ccAnswer = $latestUniversalIntake->answers->first(fn ($a) => $a->question?->code === 'chief_complaint'); + @endphp +
+

Universal intake

+

+ + {{ $assessmentStatuses[$latestUniversalIntake->status] ?? $latestUniversalIntake->status }} + + · {{ $latestUniversalIntake->created_at?->format('d M Y') ?? '—' }} +

+ @if ($ccAnswer) +

{{ \Illuminate\Support\Str::limit((string) $ccAnswer->authoritativeValue(), 120) }}

+ @endif +
+ @endif + @forelse ($recentAssessments as $assessment) +

+ + {{ $assessment->template->name }} + + · {{ ($assessmentStatuses[$assessment->status] ?? $assessment->status) }} + · {{ $assessment->created_at?->format('d M Y') ?? '—' }} +

+ @empty +

No assessments yet

+ @endforelse + @if ($canViewAssessments ?? false) +

+ @if ($canCaptureAssessment ?? false) + Start assessment + @endif + Clinical pathways + Outcomes & trends +

+ @endif +
+ @endif diff --git a/resources/views/care/reports/show.blade.php b/resources/views/care/reports/show.blade.php index 801496d..d9674b1 100644 --- a/resources/views/care/reports/show.blade.php +++ b/resources/views/care/reports/show.blade.php @@ -26,27 +26,114 @@ -
+
@if ($type === 'clinical' && isset($data['diagnoses'])) - - - - @forelse ($data['diagnoses'] as $row) - - @empty - - @endforelse - -
DiagnosisCount
{{ $row->description }}{{ $row->total }}
No diagnoses in period.
+
+ + + + @forelse ($data['diagnoses'] as $row) + + @empty + + @endforelse + +
DiagnosisCount
{{ $row->description }}{{ $row->total }}
No diagnoses in period.
+
+ @elseif ($type === 'assessments') +
+

Summary

+
+ @foreach ($data['summary'] ?? [] as $key => $value) +
+
{{ str_replace('_', ' ', $key) }}
+
{{ $value }}
+
+ @endforeach +
+
+
+

By template

+ + + + + + + + + + @forelse ($data['by_template'] ?? [] as $row) + + + + + + @empty + + @endforelse + +
TemplateStartedCompleted
{{ $row->template_name }} ({{ $row->template_code }}){{ $row->total }}{{ $row->completed }}
No assessments in period.
+
+
+

Pathway activations

+ + + + + + + + + + @forelse ($data['by_pathway'] ?? [] as $row) + + + + + + @empty + + @endforelse + +
PathwayActivationsStill active
{{ $row->pathway_name }}{{ $row->total }}{{ $row->still_active }}
No pathway activations in period.
+
+
+

Average scores

+ + + + + + + + + + + @forelse ($data['score_averages'] ?? [] as $row) + + + + + + + @empty + + @endforelse + +
InstrumentCompletionsAvg totalAvg max
{{ $row->template_code }}{{ $row->completions }}{{ $row->avg_total !== null ? round((float) $row->avg_total, 2) : '—' }}{{ $row->avg_max !== null ? round((float) $row->avg_max, 2) : '—' }}
No scored completions in period.
+
@else -
- @foreach ($data as $key => $value) -
-
{{ str_replace('_', ' ', $key) }}
-
{{ $formatValue($key, $value) }}
-
- @endforeach -
+
+
+ @foreach ($data as $key => $value) +
+
{{ str_replace('_', ' ', $key) }}
+
{{ $formatValue($key, $value) }}
+
+ @endforeach +
+
@endif
diff --git a/routes/api.php b/routes/api.php index d333e8c..5937c5c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,10 +1,12 @@ prefix('v1')->group(function Route::get('/drugs', [DrugController::class, 'index'])->name('api.drugs.index'); Route::post('/drugs', [DrugController::class, 'store'])->name('api.drugs.store'); Route::post('/prescriptions/{prescription}/dispense-stock', [DrugController::class, 'dispense'])->name('api.prescriptions.dispense-stock'); + + // Clinical assessments & pathways (gated by assessments_engine rollout) + Route::get('/assessment-templates', [AssessmentController::class, 'templates'])->name('api.assessment-templates.index'); + Route::get('/patients/{patient}/assessments', [AssessmentController::class, 'index'])->name('api.assessments.index'); + Route::post('/patients/{patient}/assessments', [AssessmentController::class, 'store'])->name('api.assessments.store'); + Route::post('/consultations/{consultation}/assessments', [AssessmentController::class, 'storeForConsultation'])->name('api.consultations.assessments.store'); + Route::get('/assessments/{assessment}', [AssessmentController::class, 'show'])->name('api.assessments.show'); + Route::put('/assessments/{assessment}', [AssessmentController::class, 'update'])->name('api.assessments.update'); + Route::post('/assessments/{assessment}/complete', [AssessmentController::class, 'complete'])->name('api.assessments.complete'); + Route::post('/assessments/{assessment}/cancel', [AssessmentController::class, 'cancel'])->name('api.assessments.cancel'); + Route::get('/assessments/{assessment}/fhir', [AssessmentController::class, 'fhir'])->name('api.assessments.fhir'); + + Route::get('/pathways', [PathwayController::class, 'catalog'])->name('api.pathways.catalog'); + Route::get('/patients/{patient}/pathways', [PathwayController::class, 'index'])->name('api.pathways.index'); + Route::post('/patients/{patient}/pathways', [PathwayController::class, 'store'])->name('api.pathways.store'); + Route::post('/patients/{patient}/pathways/{patientPathway}/deactivate', [PathwayController::class, 'deactivate'])->name('api.pathways.deactivate'); + Route::get('/consultations/{consultation}/pathway-suggestions', [PathwayController::class, 'suggestions'])->name('api.consultations.pathway-suggestions'); }); diff --git a/routes/web.php b/routes/web.php index 039c55c..6749f57 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,6 +6,7 @@ use App\Http\Controllers\NotificationController; use App\Http\Controllers\Care\AuditLogController; use App\Http\Controllers\Care\AppointmentController; use App\Http\Controllers\Care\AppointmentMeetController; +use App\Http\Controllers\Care\AssessmentController; use App\Http\Controllers\Care\BillController; use App\Http\Controllers\Care\BranchController; use App\Http\Controllers\Care\ConsultationController; @@ -16,8 +17,10 @@ use App\Http\Controllers\Care\InvestigationController; use App\Http\Controllers\Care\InvestigationTypeController; use App\Http\Controllers\Care\MemberController; use App\Http\Controllers\Care\OnboardingController; +use App\Http\Controllers\Care\OutcomeController; use App\Http\Controllers\Care\PatientController; use App\Http\Controllers\Care\PatientMessageController; +use App\Http\Controllers\Care\PathwayController; use App\Http\Controllers\Care\PractitionerController; use App\Http\Controllers\Care\PrescriptionController; use App\Http\Controllers\Care\QueueController; @@ -141,6 +144,27 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::put('/consultations/{consultation}', [ConsultationController::class, 'update'])->name('care.consultations.update'); Route::post('/consultations/{consultation}/complete', [ConsultationController::class, 'complete'])->name('care.consultations.complete'); + // Clinical assessments (gated by CareFeatures::assessments_engine) + Route::get('/patients/{patient}/assessments', [AssessmentController::class, 'index'])->name('care.assessments.index'); + Route::get('/patients/{patient}/assessments/create', [AssessmentController::class, 'create'])->name('care.assessments.create'); + Route::post('/patients/{patient}/assessments', [AssessmentController::class, 'store'])->name('care.assessments.store'); + Route::post('/consultations/{consultation}/assessments', [AssessmentController::class, 'storeForConsultation'])->name('care.consultations.assessments.store'); + Route::get('/assessments/{assessment}', [AssessmentController::class, 'show'])->name('care.assessments.show'); + Route::put('/assessments/{assessment}', [AssessmentController::class, 'update'])->name('care.assessments.update'); + Route::post('/assessments/{assessment}/complete', [AssessmentController::class, 'complete'])->name('care.assessments.complete'); + Route::post('/assessments/{assessment}/cancel', [AssessmentController::class, 'cancel'])->name('care.assessments.cancel'); + Route::get('/assessments/{assessment}/fhir', [AssessmentController::class, 'fhirExport'])->name('care.assessments.fhir'); + + // Clinical pathways (Layer 2) + Route::get('/patients/{patient}/pathways', [PathwayController::class, 'index'])->name('care.pathways.index'); + Route::post('/patients/{patient}/pathways', [PathwayController::class, 'store'])->name('care.pathways.store'); + Route::post('/patients/{patient}/pathways/{patientPathway}/deactivate', [PathwayController::class, 'deactivate'])->name('care.pathways.deactivate'); + Route::get('/consultations/{consultation}/pathway-suggestions', [PathwayController::class, 'suggestions'])->name('care.consultations.pathway-suggestions'); + + // Layer 4 — outcomes & patient trends (free per-patient; org analytics Enterprise) + Route::get('/patients/{patient}/outcomes', [OutcomeController::class, 'index'])->name('care.outcomes.index'); + Route::post('/patients/{patient}/outcomes', [OutcomeController::class, 'store'])->name('care.outcomes.store'); + Route::get('/settings', [SettingsController::class, 'edit'])->name('care.settings'); Route::put('/settings', [SettingsController::class, 'update'])->name('care.settings.update'); diff --git a/tests/Feature/CareAssessmentApiTest.php b/tests/Feature/CareAssessmentApiTest.php new file mode 100644 index 0000000..57e39c3 --- /dev/null +++ b/tests/Feature/CareAssessmentApiTest.php @@ -0,0 +1,225 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::create([ + 'public_id' => 'api-assess-user', + 'name' => 'API User', + 'email' => 'api-assess@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'API Clinic', + 'slug' => 'api-clinic', + 'timezone' => 'UTC', + 'settings' => [ + 'onboarded' => true, + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true], + ], + ]); + + $this->member = Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'doctor', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'LC-API-001', + 'first_name' => 'Ama', + 'last_name' => 'Api', + ]); + + $this->seed(AssessmentTemplateSeeder::class); + $this->seed(ClinicalPathwaySeeder::class); + + Sanctum::actingAs($this->user); + } + + public function test_api_lists_templates_and_404_when_flag_off(): void + { + $this->getJson('/api/v1/assessment-templates') + ->assertOk() + ->assertJsonPath('data.0.code', fn ($c) => is_string($c)); + + $this->organization->update([ + 'settings' => array_merge($this->organization->settings ?? [], [ + 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => false], + ]), + ]); + + $this->getJson('/api/v1/assessment-templates')->assertNotFound(); + } + + public function test_api_assessment_lifecycle_with_uuids(): void + { + $create = $this->postJson("/api/v1/patients/{$this->patient->uuid}/assessments", [ + 'template_code' => 'mrs', + ])->assertSuccessful(); + + $uuid = $create->json('uuid'); + $this->assertNotEmpty($uuid); + + $updated = $this->putJson("/api/v1/assessments/{$uuid}", [ + 'answers' => ['mrs_score' => '3'], + ])->assertOk(); + + $this->assertEquals(3, (float) $updated->json('answers.mrs_score')); + + $completed = $this->postJson("/api/v1/assessments/{$uuid}/complete") + ->assertOk() + ->assertJsonPath('status', 'completed'); + + $this->assertEquals(3, (float) $completed->json('score.total_score')); + + $this->putJson("/api/v1/assessments/{$uuid}", [ + 'answers' => ['mrs_score' => '4'], + ])->assertStatus(422); + } + + public function test_api_pathway_suggestions_and_activate(): void + { + $visit = Visit::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + + $consultation = Consultation::create([ + 'owner_ref' => $this->user->public_id, + 'visit_id' => $visit->id, + 'patient_id' => $this->patient->id, + 'status' => Consultation::STATUS_DRAFT, + 'started_at' => now(), + ]); + + Diagnosis::create([ + 'owner_ref' => $this->user->public_id, + 'consultation_id' => $consultation->id, + 'code' => 'I63.9', + 'description' => 'Cerebral infarction', + 'is_primary' => true, + ]); + + $this->getJson("/api/v1/consultations/{$consultation->uuid}/pathway-suggestions") + ->assertOk() + ->assertJsonPath('data.0.pathway_code', 'stroke'); + + $this->postJson("/api/v1/patients/{$this->patient->uuid}/pathways", [ + 'pathway_code' => 'stroke', + 'consultation_uuid' => $consultation->uuid, + ]) + ->assertCreated() + ->assertJsonPath('pathway_code', 'stroke') + ->assertJsonPath('status', 'active'); + + $this->getJson("/api/v1/patients/{$this->patient->uuid}/pathways") + ->assertOk() + ->assertJsonPath('active.0.pathway_code', 'stroke'); + + $this->assertGreaterThanOrEqual(2, Assessment::where('status', Assessment::STATUS_DRAFT)->count()); + } + + public function test_api_tenant_isolation(): void + { + $other = User::create([ + 'public_id' => 'other-api', + 'name' => 'Other', + 'email' => 'other-api@example.com', + ]); + $otherOrg = Organization::create([ + 'owner_ref' => $other->public_id, + 'name' => 'Other', + 'slug' => 'other-api', + 'timezone' => 'UTC', + 'settings' => [ + 'onboarded' => true, + 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true], + ], + ]); + Member::create([ + 'owner_ref' => $other->public_id, + 'organization_id' => $otherOrg->id, + 'user_ref' => $other->public_id, + 'role' => 'doctor', + ]); + $otherPatient = Patient::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $other->public_id, + 'organization_id' => $otherOrg->id, + 'patient_number' => 'LC-OTHER', + 'first_name' => 'X', + 'last_name' => 'Y', + ]); + + $this->getJson("/api/v1/patients/{$otherPatient->uuid}/assessments")->assertNotFound(); + } + + public function test_api_catalog_includes_pr10_pathways(): void + { + $this->getJson('/api/v1/pathways') + ->assertOk() + ->assertJsonFragment(['code' => 'copd']) + ->assertJsonFragment(['code' => 'heart_failure']) + ->assertJsonFragment(['code' => 'dementia']); + + $this->assertNotNull(ClinicalPathway::findByCode('pregnancy')); + $this->assertNotNull(ClinicalPathway::findByCode('orthopaedics')); + } +} diff --git a/tests/Feature/CareAssessmentCaptureTest.php b/tests/Feature/CareAssessmentCaptureTest.php new file mode 100644 index 0000000..c43b8a2 --- /dev/null +++ b/tests/Feature/CareAssessmentCaptureTest.php @@ -0,0 +1,455 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::create([ + 'public_id' => 'assess-capture-user', + 'name' => 'Capture User', + 'email' => 'capture@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Capture Clinic', + 'slug' => 'capture-clinic', + 'timezone' => 'UTC', + 'settings' => [ + 'onboarded' => true, + 'rollout' => [ + CareFeatures::ASSESSMENTS_ENGINE => true, + ], + ], + ]); + + $this->member = Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'doctor', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'LC-CAP-001', + 'first_name' => 'Ama', + 'last_name' => 'Mensah', + ]); + + $this->universal = AssessmentTemplate::create([ + 'code' => 'universal_intake', + 'name' => 'Universal Intake', + 'category' => AssessmentTemplate::CATEGORY_UNIVERSAL, + 'version' => 1, + 'is_current' => true, + 'is_active' => true, + 'meta' => ['capture_roles' => ['doctor', 'nurse']], + ]); + + AssessmentQuestion::create([ + 'template_id' => $this->universal->id, + 'code' => 'chief_complaint', + 'label' => 'Chief complaint', + 'answer_type' => AssessmentQuestion::TYPE_TEXT, + 'is_required' => true, + 'sort_order' => 1, + ]); + + AssessmentQuestion::create([ + 'template_id' => $this->universal->id, + 'code' => 'pain_score', + 'label' => 'Pain score', + 'answer_type' => AssessmentQuestion::TYPE_SCALE, + 'options' => ['min' => 0, 'max' => 10], + 'is_required' => false, + 'sort_order' => 2, + ]); + + $this->nihss = AssessmentTemplate::create([ + 'code' => 'nihss', + 'name' => 'NIH Stroke Scale', + 'category' => AssessmentTemplate::CATEGORY_DISEASE, + 'version' => 1, + 'is_current' => true, + 'is_active' => true, + 'scoring_strategy' => 'sum_items', + 'meta' => ['capture_roles' => ['doctor']], + ]); + + AssessmentQuestion::create([ + 'template_id' => $this->nihss->id, + 'code' => 'loc', + 'label' => 'Level of consciousness', + 'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM, + 'options' => [ + 'choices' => [ + ['code' => '0', 'label' => 'Alert', 'score' => 0], + ['code' => '1', 'label' => 'Not alert', 'score' => 1], + ['code' => '2', 'label' => 'Obtunded', 'score' => 2], + ], + ], + 'is_required' => true, + 'sort_order' => 1, + ]); + } + + protected function setMemberRole(string $role): void + { + $this->member->update(['role' => $role]); + } + + public function test_routes_404_when_feature_flag_disabled(): void + { + $this->organization->update([ + 'settings' => ['onboarded' => true, 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => false]], + ]); + + $this->actingAs($this->user) + ->get(route('care.assessments.index', $this->patient)) + ->assertNotFound(); + } + + public function test_doctor_can_start_nihss(): void + { + $this->setMemberRole('doctor'); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), [ + 'template_code' => 'nihss', + ]) + ->assertRedirect(); + + $assessment = Assessment::first(); + $this->assertNotNull($assessment); + $this->assertSame(Assessment::STATUS_DRAFT, $assessment->status); + $this->assertSame($this->nihss->id, $assessment->template_id); + $this->assertDatabaseHas('care_audit_logs', ['action' => 'assessment.started']); + } + + public function test_nurse_can_start_universal_but_not_nihss(): void + { + $this->setMemberRole('nurse'); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), [ + 'template_code' => 'universal_intake', + ]) + ->assertRedirect(); + + $this->assertSame(1, Assessment::count()); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), [ + 'template_code' => 'nihss', + ]) + ->assertForbidden(); + } + + public function test_hospital_admin_can_start_nihss(): void + { + $this->setMemberRole('hospital_admin'); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), [ + 'template_code' => 'nihss', + ]) + ->assertRedirect(); + + $this->assertSame(1, Assessment::where('template_id', $this->nihss->id)->count()); + } + + public function test_super_admin_can_start_nihss(): void + { + $this->setMemberRole('super_admin'); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), [ + 'template_code' => 'nihss', + ]) + ->assertRedirect(); + + $this->assertSame(1, Assessment::count()); + } + + public function test_start_is_idempotent_for_same_draft(): void + { + $this->setMemberRole('doctor'); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss']) + ->assertRedirect(); + + $first = Assessment::first(); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss']) + ->assertRedirect(route('care.assessments.show', $first)); + + $this->assertSame(1, Assessment::count()); + } + + public function test_save_answers_complete_and_immutable(): void + { + $this->setMemberRole('doctor'); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'universal_intake']) + ->assertRedirect(); + + $assessment = Assessment::first(); + + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), [ + 'answers' => [ + 'chief_complaint' => 'Headache', + 'pain_score' => 4, + ], + 'notes' => 'Initial', + ]) + ->assertRedirect(route('care.assessments.show', $assessment)); + + $this->assertDatabaseHas('care_assessment_answers', [ + 'assessment_id' => $assessment->id, + 'value_text' => 'Headache', + ]); + $this->assertDatabaseHas('care_assessment_answers', [ + 'assessment_id' => $assessment->id, + 'value_number' => 4, + ]); + + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)) + ->assertRedirect(); + + $assessment->refresh(); + $this->assertSame(Assessment::STATUS_COMPLETED, $assessment->status); + $this->assertNotNull($assessment->completed_at); + $this->assertDatabaseHas('care_audit_logs', ['action' => 'assessment.completed']); + + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), [ + 'answers' => ['chief_complaint' => 'Changed'], + ]) + ->assertSessionHasErrors('status'); + + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)) + ->assertSessionHasErrors('status'); + } + + public function test_complete_requires_required_answers(): void + { + $this->setMemberRole('nurse'); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'universal_intake']) + ->assertRedirect(); + + $assessment = Assessment::first(); + + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)) + ->assertSessionHasErrors('answers.chief_complaint'); + + $this->assertSame(Assessment::STATUS_DRAFT, $assessment->fresh()->status); + } + + public function test_cancel_draft_only(): void + { + $this->setMemberRole('doctor'); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'universal_intake']) + ->assertRedirect(); + + $assessment = Assessment::first(); + + $this->actingAs($this->user) + ->post(route('care.assessments.cancel', $assessment)) + ->assertRedirect(route('care.assessments.index', $this->patient)); + + $this->assertSame(Assessment::STATUS_CANCELLED, $assessment->fresh()->status); + $this->assertDatabaseHas('care_audit_logs', ['action' => 'assessment.cancelled']); + } + + public function test_consultation_scoped_start_links_consultation_and_visit(): void + { + $this->setMemberRole('doctor'); + + $visit = Visit::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + + $consultation = Consultation::create([ + 'owner_ref' => $this->user->public_id, + 'visit_id' => $visit->id, + 'patient_id' => $this->patient->id, + 'status' => Consultation::STATUS_DRAFT, + 'started_at' => now(), + ]); + + $this->actingAs($this->user) + ->post(route('care.consultations.assessments.store', $consultation), [ + 'template_code' => 'universal_intake', + ]) + ->assertRedirect(); + + $assessment = Assessment::first(); + $this->assertSame($consultation->id, $assessment->consultation_id); + $this->assertSame($visit->id, $assessment->visit_id); + $this->assertSame($this->branch->id, $assessment->branch_id); + } + + public function test_public_bodies_use_consultation_uuid(): void + { + $this->setMemberRole('doctor'); + + $visit = Visit::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + + $consultation = Consultation::create([ + 'owner_ref' => $this->user->public_id, + 'visit_id' => $visit->id, + 'patient_id' => $this->patient->id, + 'status' => Consultation::STATUS_DRAFT, + 'started_at' => now(), + ]); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), [ + 'template_code' => 'universal_intake', + 'consultation_uuid' => $consultation->uuid, + ]) + ->assertRedirect(); + + $assessment = Assessment::first(); + $this->assertSame($consultation->id, $assessment->consultation_id); + } + + public function test_tenant_isolation_returns_404(): void + { + $this->setMemberRole('doctor'); + + $otherUser = User::create([ + 'public_id' => 'other-owner', + 'name' => 'Other', + 'email' => 'other@example.com', + ]); + $otherOrg = Organization::create([ + 'owner_ref' => $otherUser->public_id, + 'name' => 'Other Clinic', + 'slug' => 'other-clinic', + 'timezone' => 'UTC', + 'settings' => ['onboarded' => true, 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true]], + ]); + Member::create([ + 'owner_ref' => $otherUser->public_id, + 'organization_id' => $otherOrg->id, + 'user_ref' => $otherUser->public_id, + 'role' => 'doctor', + ]); + $otherPatient = Patient::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $otherUser->public_id, + 'organization_id' => $otherOrg->id, + 'patient_number' => 'LC-OTHER', + 'first_name' => 'Other', + 'last_name' => 'Patient', + ]); + + $this->actingAs($this->user) + ->get(route('care.assessments.index', $otherPatient)) + ->assertNotFound(); + } + + public function test_score_item_stores_numeric_score_from_choice_code(): void + { + $this->setMemberRole('doctor'); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss']) + ->assertRedirect(); + + $assessment = Assessment::first(); + + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), [ + 'answers' => ['loc' => '2'], + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('care_assessment_answers', [ + 'assessment_id' => $assessment->id, + 'value_number' => 2, + 'value_text' => null, + ]); + } + + public function test_receptionist_cannot_view_assessments(): void + { + $this->setMemberRole('receptionist'); + + $this->actingAs($this->user) + ->get(route('care.assessments.index', $this->patient)) + ->assertForbidden(); + } +} diff --git a/tests/Feature/CareAssessmentEngineTest.php b/tests/Feature/CareAssessmentEngineTest.php new file mode 100644 index 0000000..99c9881 --- /dev/null +++ b/tests/Feature/CareAssessmentEngineTest.php @@ -0,0 +1,339 @@ +user = User::create([ + 'public_id' => 'test-user-assess-001', + 'name' => 'Assessment Test User', + 'email' => 'assess@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Assess Clinic', + 'slug' => 'assess-clinic', + 'timezone' => 'UTC', + 'settings' => ['onboarded' => true], + ]); + + Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'doctor', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'LC-2026-ASSESS-001', + 'first_name' => 'Ama', + 'last_name' => 'Mensah', + ]); + } + + public function test_template_auto_generates_uuid_and_has_no_owner_ref(): void + { + $template = AssessmentTemplate::create([ + 'code' => 'universal_intake', + 'name' => 'Universal Intake', + 'category' => AssessmentTemplate::CATEGORY_UNIVERSAL, + 'version' => 1, + 'is_current' => true, + 'is_active' => true, + 'meta' => ['capture_roles' => ['nurse', 'doctor']], + ]); + + $this->assertNotEmpty($template->uuid); + $this->assertTrue(Str::isUuid($template->uuid)); + $this->assertNull($template->organization_id); + $this->assertFalse(isset($template->getAttributes()['owner_ref'])); + $this->assertSame(['capture_roles' => ['nurse', 'doctor']], $template->meta); + $this->assertSame('uuid', $template->getRouteKeyName()); + } + + public function test_system_template_code_version_is_unique(): void + { + AssessmentTemplate::create([ + 'code' => 'nihss', + 'name' => 'NIHSS v1', + 'category' => AssessmentTemplate::CATEGORY_DISEASE, + 'version' => 1, + 'is_current' => true, + ]); + + $this->expectException(QueryException::class); + + AssessmentTemplate::create([ + 'code' => 'nihss', + 'name' => 'NIHSS v1 duplicate', + 'category' => AssessmentTemplate::CATEGORY_DISEASE, + 'version' => 1, + 'is_current' => false, + ]); + } + + public function test_same_code_different_versions_allowed(): void + { + AssessmentTemplate::create([ + 'code' => 'mrs', + 'name' => 'mRS v1', + 'category' => AssessmentTemplate::CATEGORY_DISEASE, + 'version' => 1, + 'is_current' => false, + ]); + + $v2 = AssessmentTemplate::create([ + 'code' => 'mrs', + 'name' => 'mRS v2', + 'category' => AssessmentTemplate::CATEGORY_DISEASE, + 'version' => 2, + 'is_current' => true, + ]); + + $this->assertSame(2, AssessmentTemplate::where('code', 'mrs')->count()); + $this->assertTrue($v2->is_current); + $this->assertSame($v2->id, AssessmentTemplate::currentSystemByCode('mrs')?->id); + } + + public function test_question_unique_per_template_and_ordered_relation(): void + { + $template = AssessmentTemplate::create([ + 'code' => 'fixture_scale', + 'name' => 'Fixture', + 'category' => AssessmentTemplate::CATEGORY_SCREENING, + 'version' => 1, + 'is_current' => true, + ]); + + AssessmentQuestion::create([ + 'template_id' => $template->id, + 'code' => 'item_b', + 'label' => 'Item B', + 'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM, + 'sort_order' => 2, + 'is_required' => true, + ]); + + AssessmentQuestion::create([ + 'template_id' => $template->id, + 'code' => 'item_a', + 'label' => 'Item A', + 'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM, + 'sort_order' => 1, + 'is_required' => true, + ]); + + $codes = $template->questions()->pluck('code')->all(); + $this->assertSame(['item_a', 'item_b'], $codes); + + $this->expectException(QueryException::class); + AssessmentQuestion::create([ + 'template_id' => $template->id, + 'code' => 'item_a', + 'label' => 'Duplicate', + 'answer_type' => AssessmentQuestion::TYPE_TEXT, + 'sort_order' => 3, + ]); + } + + public function test_assessment_instance_relations_and_owned_scope(): void + { + $template = AssessmentTemplate::create([ + 'code' => 'universal_intake', + 'name' => 'Universal Intake', + 'category' => AssessmentTemplate::CATEGORY_UNIVERSAL, + 'version' => 1, + 'is_current' => true, + ]); + + $question = AssessmentQuestion::create([ + 'template_id' => $template->id, + 'code' => 'chief_complaint', + 'label' => 'Chief complaint', + 'answer_type' => AssessmentQuestion::TYPE_TEXT, + 'sort_order' => 1, + 'is_required' => true, + ]); + + $visit = Visit::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + + $consultation = Consultation::create([ + 'owner_ref' => $this->user->public_id, + 'visit_id' => $visit->id, + 'patient_id' => $this->patient->id, + 'status' => Consultation::STATUS_DRAFT, + 'started_at' => now(), + ]); + + $assessment = Assessment::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'template_id' => $template->id, + 'consultation_id' => $consultation->id, + 'visit_id' => $visit->id, + 'status' => Assessment::STATUS_DRAFT, + 'started_by' => $this->user->public_id, + ]); + + $this->assertNotEmpty($assessment->uuid); + $this->assertTrue(Str::isUuid($assessment->uuid)); + $this->assertTrue($assessment->isDraft()); + $this->assertFalse($assessment->isCompleted()); + + AssessmentAnswer::create([ + 'owner_ref' => $this->user->public_id, + 'assessment_id' => $assessment->id, + 'question_id' => $question->id, + 'value_text' => 'Headache for 2 days', + ]); + + $this->assertSame(1, $assessment->answers()->count()); + $this->assertSame('Headache for 2 days', $assessment->answers->first()->authoritativeValue()); + $this->assertTrue($this->patient->assessments->contains($assessment)); + $this->assertTrue($consultation->assessments->contains($assessment)); + $this->assertTrue($visit->assessments->contains($assessment)); + $this->assertSame($template->id, $assessment->template->id); + + $this->assertSame(1, Assessment::query()->owned($this->user->public_id)->count()); + $this->assertSame(0, Assessment::query()->owned('other-owner')->count()); + } + + public function test_answer_unique_per_assessment_and_question(): void + { + $template = AssessmentTemplate::create([ + 'code' => 'fixture', + 'name' => 'Fixture', + 'category' => AssessmentTemplate::CATEGORY_UNIVERSAL, + 'version' => 1, + 'is_current' => true, + ]); + + $question = AssessmentQuestion::create([ + 'template_id' => $template->id, + 'code' => 'pain_score', + 'label' => 'Pain', + 'answer_type' => AssessmentQuestion::TYPE_NUMBER, + 'sort_order' => 1, + ]); + + $assessment = Assessment::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'template_id' => $template->id, + 'status' => Assessment::STATUS_DRAFT, + ]); + + AssessmentAnswer::create([ + 'owner_ref' => $this->user->public_id, + 'assessment_id' => $assessment->id, + 'question_id' => $question->id, + 'value_number' => 5, + ]); + + $this->expectException(QueryException::class); + AssessmentAnswer::create([ + 'owner_ref' => $this->user->public_id, + 'assessment_id' => $assessment->id, + 'question_id' => $question->id, + 'value_number' => 7, + ]); + } + + public function test_deleting_assessment_cascades_answers_but_not_template(): void + { + $template = AssessmentTemplate::create([ + 'code' => 'cascade_fixture', + 'name' => 'Cascade', + 'category' => AssessmentTemplate::CATEGORY_UNIVERSAL, + 'version' => 1, + 'is_current' => true, + ]); + + $question = AssessmentQuestion::create([ + 'template_id' => $template->id, + 'code' => 'q1', + 'label' => 'Q1', + 'answer_type' => AssessmentQuestion::TYPE_BOOLEAN, + 'sort_order' => 1, + ]); + + $assessment = Assessment::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'patient_id' => $this->patient->id, + 'template_id' => $template->id, + 'status' => Assessment::STATUS_DRAFT, + ]); + + AssessmentAnswer::create([ + 'owner_ref' => $this->user->public_id, + 'assessment_id' => $assessment->id, + 'question_id' => $question->id, + 'value_boolean' => true, + ]); + + $assessmentId = $assessment->id; + $assessment->forceDelete(); + + $this->assertDatabaseMissing('care_assessments', ['id' => $assessmentId]); + $this->assertDatabaseMissing('care_assessment_answers', ['assessment_id' => $assessmentId]); + $this->assertDatabaseHas('care_assessment_templates', ['id' => $template->id]); + $this->assertDatabaseHas('care_assessment_questions', ['id' => $question->id]); + } +} diff --git a/tests/Feature/CareAssessmentExtrasTest.php b/tests/Feature/CareAssessmentExtrasTest.php new file mode 100644 index 0000000..162736b --- /dev/null +++ b/tests/Feature/CareAssessmentExtrasTest.php @@ -0,0 +1,145 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::create([ + 'public_id' => 'extras-user', + 'name' => 'Extras', + 'email' => 'extras@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Extras Clinic', + 'slug' => 'extras-clinic', + 'timezone' => 'UTC', + 'settings' => [ + 'onboarded' => true, + 'plan' => 'enterprise', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true], + ], + ]); + + Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'hospital_admin', + ]); + + Branch::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'patient_number' => 'LC-EX-001', + 'first_name' => 'Extra', + 'last_name' => 'Patient', + ]); + + $this->seed(AssessmentTemplateSeeder::class); + } + + public function test_enterprise_assessment_analytics_report(): void + { + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'mrs']) + ->assertRedirect(); + + $assessment = Assessment::first(); + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), ['answers' => ['mrs_score' => '2']]); + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)); + + $this->actingAs($this->user) + ->get(route('care.reports.show', ['type' => 'assessments'])) + ->assertOk() + ->assertSee('Assessment analytics') + ->assertSee('By template') + ->assertSee('mrs'); + } + + public function test_pro_plan_blocked_from_assessment_analytics_without_feature(): void + { + // Pro can access paid reports middleware, but lacks assessment_analytics (Enterprise-only). + $this->organization->update([ + 'settings' => [ + 'onboarded' => true, + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true], + ], + ]); + + $this->actingAs($this->user) + ->get(route('care.reports.show', ['type' => 'assessments'])) + ->assertForbidden(); + } + + public function test_fhir_export_web_and_api(): void + { + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'mrs']) + ->assertRedirect(); + + $assessment = Assessment::first(); + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), ['answers' => ['mrs_score' => '1']]); + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)); + + $bundle = app(FhirAssessmentExporter::class)->exportBundle($assessment->fresh(['template.questions', 'answers.question', 'patient', 'score'])); + $this->assertSame('Bundle', $bundle['resourceType']); + $this->assertSame('Questionnaire', $bundle['entry'][0]['resource']['resourceType']); + $this->assertSame('QuestionnaireResponse', $bundle['entry'][1]['resource']['resourceType']); + $this->assertSame('completed', $bundle['entry'][1]['resource']['status']); + + $this->actingAs($this->user) + ->get(route('care.assessments.fhir', $assessment).'?download=1') + ->assertOk(); + + Sanctum::actingAs($this->user); + $this->getJson('/api/v1/assessments/'.$assessment->uuid.'/fhir') + ->assertOk() + ->assertJsonPath('resourceType', 'Bundle') + ->assertJsonPath('entry.1.resource.resourceType', 'QuestionnaireResponse'); + } +} diff --git a/tests/Feature/CareAssessmentScoringTest.php b/tests/Feature/CareAssessmentScoringTest.php new file mode 100644 index 0000000..40e490b --- /dev/null +++ b/tests/Feature/CareAssessmentScoringTest.php @@ -0,0 +1,288 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::create([ + 'public_id' => 'scoring-user', + 'name' => 'Scoring User', + 'email' => 'scoring@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Score Clinic', + 'slug' => 'score-clinic', + 'timezone' => 'UTC', + 'settings' => [ + 'onboarded' => true, + 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true], + ], + ]); + + $this->member = Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'doctor', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'LC-SCORE-001', + 'first_name' => 'Ama', + 'last_name' => 'Score', + ]); + } + + public function test_sum_items_materializes_total_and_max(): void + { + $template = AssessmentTemplate::create([ + 'code' => 'nihss_fixture', + 'name' => 'NIHSS fixture', + 'category' => AssessmentTemplate::CATEGORY_DISEASE, + 'version' => 1, + 'is_current' => true, + 'scoring_strategy' => 'sum_items', + 'meta' => ['capture_roles' => ['doctor']], + ]); + + AssessmentQuestion::create([ + 'template_id' => $template->id, + 'code' => 'loc', + 'label' => 'LOC', + 'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM, + 'options' => [ + 'choices' => [ + ['code' => '0', 'label' => 'Alert', 'score' => 0], + ['code' => '2', 'label' => 'Obtunded', 'score' => 2], + ], + ], + 'is_required' => true, + 'sort_order' => 1, + 'score_key' => 'loc', + ]); + + AssessmentQuestion::create([ + 'template_id' => $template->id, + 'code' => 'motor', + 'label' => 'Motor', + 'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM, + 'options' => [ + 'max' => 4, + 'choices' => [ + ['code' => '0', 'label' => 'None', 'score' => 0], + ['code' => '3', 'label' => 'Severe', 'score' => 3], + ], + ], + 'is_required' => true, + 'sort_order' => 2, + 'score_key' => 'motor', + ]); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss_fixture']) + ->assertRedirect(); + + $assessment = Assessment::first(); + + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), [ + 'answers' => ['loc' => '2', 'motor' => '3'], + ]) + ->assertRedirect(); + + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)) + ->assertRedirect(); + + $score = AssessmentScore::where('assessment_id', $assessment->id)->first(); + $this->assertNotNull($score); + $this->assertEquals(5, (float) $score->total_score); + $this->assertEquals(6, (float) $score->max_score); // 2 + 4 + $this->assertSame('nihss_fixture', $score->template_code); + $this->assertEquals(2, $score->subscores['loc']); + $this->assertEquals(3, $score->subscores['motor']); + + $this->actingAs($this->user) + ->get(route('care.assessments.show', $assessment)) + ->assertOk() + ->assertSee('Score') + ->assertSee('5'); + } + + public function test_single_value_strategy_for_mrs_style(): void + { + $template = AssessmentTemplate::create([ + 'code' => 'mrs_fixture', + 'name' => 'mRS fixture', + 'category' => AssessmentTemplate::CATEGORY_DISEASE, + 'version' => 1, + 'is_current' => true, + 'scoring_strategy' => 'single_value', + 'meta' => ['capture_roles' => ['doctor']], + ]); + + AssessmentQuestion::create([ + 'template_id' => $template->id, + 'code' => 'mrs_score', + 'label' => 'mRS', + 'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM, + 'options' => [ + 'min' => 0, + 'max' => 6, + 'choices' => [ + ['code' => '0', 'label' => 'None', 'score' => 0], + ['code' => '3', 'label' => 'Moderate', 'score' => 3], + ['code' => '6', 'label' => 'Dead', 'score' => 6], + ], + ], + 'is_required' => true, + 'sort_order' => 1, + 'score_key' => 'total', + ]); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'mrs_fixture']) + ->assertRedirect(); + + $assessment = Assessment::first(); + + $this->actingAs($this->user) + ->from(route('care.assessments.show', $assessment)) + ->put(route('care.assessments.update', $assessment), [ + 'answers' => ['mrs_score' => '3'], + ]); + + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)) + ->assertRedirect(); + + $preview = app(ScoringService::class)->preview($assessment->fresh(['template.questions', 'answers.question'])); + $this->assertEquals(3, $preview['total']); + $this->assertEquals(6, $preview['max']); + + $score = AssessmentScore::first(); + $this->assertEquals(3, (float) $score->total_score); + $this->assertEquals(6, (float) $score->max_score); + } + + public function test_null_scoring_strategy_skips_score_row(): void + { + $template = AssessmentTemplate::create([ + 'code' => 'no_score', + 'name' => 'No score', + 'category' => AssessmentTemplate::CATEGORY_UNIVERSAL, + 'version' => 1, + 'is_current' => true, + 'scoring_strategy' => null, + 'meta' => ['capture_roles' => ['doctor', 'nurse']], + ]); + + AssessmentQuestion::create([ + 'template_id' => $template->id, + 'code' => 'note', + 'label' => 'Note', + 'answer_type' => AssessmentQuestion::TYPE_TEXT, + 'is_required' => true, + 'sort_order' => 1, + ]); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'no_score']) + ->assertRedirect(); + + $assessment = Assessment::first(); + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), [ + 'answers' => ['note' => 'hello'], + ]); + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)) + ->assertRedirect(); + + $this->assertSame(0, AssessmentScore::count()); + $this->assertTrue($assessment->fresh()->isCompleted()); + } + + public function test_complete_fails_when_score_items_missing(): void + { + $template = AssessmentTemplate::create([ + 'code' => 'need_scores', + 'name' => 'Need scores', + 'category' => AssessmentTemplate::CATEGORY_DISEASE, + 'version' => 1, + 'is_current' => true, + 'scoring_strategy' => 'sum_items', + 'meta' => ['capture_roles' => ['doctor']], + ]); + + AssessmentQuestion::create([ + 'template_id' => $template->id, + 'code' => 'item_a', + 'label' => 'A', + 'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM, + 'options' => ['choices' => [['code' => '1', 'label' => '1', 'score' => 1]]], + 'is_required' => false, + 'sort_order' => 1, + ]); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'need_scores']) + ->assertRedirect(); + + $assessment = Assessment::first(); + + $this->actingAs($this->user) + ->from(route('care.assessments.show', $assessment)) + ->post(route('care.assessments.complete', $assessment)) + ->assertSessionHasErrors('answers.item_a'); + + $this->assertSame(Assessment::STATUS_DRAFT, $assessment->fresh()->status); + $this->assertSame(0, AssessmentScore::count()); + } +} diff --git a/tests/Feature/CareContentPacksTest.php b/tests/Feature/CareContentPacksTest.php new file mode 100644 index 0000000..cc2fbfd --- /dev/null +++ b/tests/Feature/CareContentPacksTest.php @@ -0,0 +1,94 @@ +seed(AssessmentTemplateSeeder::class); + + $expected = [ + 'universal_intake', + 'nihss', + 'mrs', + 'barthel', + 'gcs', + 'stroke_swallow', + 'stroke_clinical', + 'diabetes_core', + 'outcome_core', + 'heart_failure_core', + 'ckd_core', + 'hypertension_core', + 'asthma_core', + 'copd_core', + 'dementia_core', + 'parkinsons_core', + 'cancer_core', + 'pregnancy_core', + 'orthopaedics_core', + ]; + + foreach ($expected as $code) { + $this->assertNotNull( + AssessmentTemplate::currentSystemByCode($code), + "Missing template {$code}" + ); + } + } + + public function test_all_pathways_seed_with_required_bindings(): void + { + $this->seed(AssessmentTemplateSeeder::class); + $this->seed(ClinicalPathwaySeeder::class); + + $expected = [ + 'stroke', + 'diabetes', + 'heart_failure', + 'ckd', + 'hypertension', + 'asthma', + 'copd', + 'dementia', + 'parkinsons', + 'cancer', + 'pregnancy', + 'orthopaedics', + ]; + + foreach ($expected as $code) { + $pathway = ClinicalPathway::findByCode($code); + $this->assertNotNull($pathway, "Missing pathway {$code}"); + $this->assertGreaterThanOrEqual( + 1, + $pathway->templates()->where('is_required_on_activation', true)->count(), + "Pathway {$code} needs a required template" + ); + } + } + + public function test_copd_and_heart_failure_scoring_templates(): void + { + $this->seed(AssessmentTemplateSeeder::class); + + $copd = AssessmentTemplate::currentSystemByCode('copd_core'); + $hf = AssessmentTemplate::currentSystemByCode('heart_failure_core'); + + $this->assertSame('single_value', $copd->scoring_strategy); + $this->assertSame('single_value', $hf->scoring_strategy); + $this->assertTrue($copd->questions()->where('code', 'mmrc')->exists()); + $this->assertTrue($hf->questions()->where('code', 'nyha')->exists()); + } +} diff --git a/tests/Feature/CareDiabetesPathwayTest.php b/tests/Feature/CareDiabetesPathwayTest.php new file mode 100644 index 0000000..15aa235 --- /dev/null +++ b/tests/Feature/CareDiabetesPathwayTest.php @@ -0,0 +1,199 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::create([ + 'public_id' => 'dm-user', + 'name' => 'DM User', + 'email' => 'dm@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'DM Clinic', + 'slug' => 'dm-clinic', + 'timezone' => 'UTC', + 'settings' => [ + 'onboarded' => true, + 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true], + ], + ]); + + $this->member = Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'doctor', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'LC-DM-001', + 'first_name' => 'Abena', + 'last_name' => 'Darko', + ]); + + $this->seed(AssessmentTemplateSeeder::class); + $this->seed(ClinicalPathwaySeeder::class); + } + + public function test_diabetes_pack_and_pathway_seeded(): void + { + $template = AssessmentTemplate::currentSystemByCode('diabetes_core'); + $this->assertNotNull($template); + $this->assertTrue($template->questions()->where('code', 'foot_assessment')->exists()); + $this->assertTrue($template->questions()->where('code', 'hba1c')->exists()); + + $pathway = ClinicalPathway::findByCode('diabetes'); + $this->assertNotNull($pathway); + $this->assertTrue( + $pathway->templates()->where('template_code', 'diabetes_core')->where('is_required_on_activation', true)->exists() + ); + } + + public function test_matcher_matches_type2_and_e11(): void + { + $matcher = app(PathwayMatcher::class); + + $byCode = $matcher->match([['code' => 'E11.9', 'description' => '']]); + $this->assertTrue($byCode->contains(fn ($r) => $r['pathway_code'] === 'diabetes')); + + $byKw = $matcher->match([['code' => '', 'description' => 'Type 2 diabetes mellitus']]); + $this->assertTrue($byKw->contains(fn ($r) => $r['pathway_code'] === 'diabetes')); + } + + public function test_activate_diabetes_creates_core_draft_and_comorbidity_with_stroke(): void + { + $visit = Visit::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + + $consultation = Consultation::create([ + 'owner_ref' => $this->user->public_id, + 'visit_id' => $visit->id, + 'patient_id' => $this->patient->id, + 'status' => Consultation::STATUS_DRAFT, + 'started_at' => now(), + ]); + + Diagnosis::create([ + 'owner_ref' => $this->user->public_id, + 'consultation_id' => $consultation->id, + 'code' => 'E11.9', + 'description' => 'Type 2 diabetes mellitus', + 'is_primary' => true, + ]); + + $this->actingAs($this->user) + ->post(route('care.pathways.store', $this->patient), [ + 'pathway_code' => 'diabetes', + 'consultation_uuid' => $consultation->uuid, + ]) + ->assertRedirect(); + + $this->assertSame(1, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count()); + $this->assertTrue( + Assessment::query()->whereHas('template', fn ($q) => $q->where('code', 'diabetes_core'))->exists() + ); + + // Comorbidity: also activate stroke + app(PathwayService::class)->activate( + $this->patient, + ClinicalPathway::findByCode('stroke'), + $this->user->public_id, + $this->member, + ['consultation' => $consultation, 'actor' => $this->user->public_id], + ); + + $this->assertSame(2, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count()); + $this->assertGreaterThanOrEqual(3, Assessment::where('status', Assessment::STATUS_DRAFT)->count()); // dm + nihss + mrs + + $this->actingAs($this->user) + ->get(route('care.consultations.show', $consultation)) + ->assertOk() + ->assertSee('Diabetes') + ->assertSee('Stroke') + ->assertSee('diabetes_core'); + } + + public function test_nurse_can_complete_diabetes_core(): void + { + $this->member->update(['role' => 'nurse']); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'diabetes_core']) + ->assertRedirect(); + + $assessment = Assessment::first(); + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), [ + 'answers' => [ + 'foot_assessment' => 'normal', + 'hba1c' => 7.2, + 'diet_adherence' => 'good', + ], + ]); + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)) + ->assertRedirect(); + + $this->assertTrue($assessment->fresh()->isCompleted()); + } +} diff --git a/tests/Feature/CareOutcomeTrendTest.php b/tests/Feature/CareOutcomeTrendTest.php new file mode 100644 index 0000000..f3baae4 --- /dev/null +++ b/tests/Feature/CareOutcomeTrendTest.php @@ -0,0 +1,158 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::create([ + 'public_id' => 'outcome-user', + 'name' => 'Outcome User', + 'email' => 'outcome@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Outcome Clinic', + 'slug' => 'outcome-clinic', + 'timezone' => 'UTC', + 'settings' => [ + 'onboarded' => true, + 'plan' => 'free', + 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true], + ], + ]); + + $this->member = Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'nurse', + ]); + + Branch::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'patient_number' => 'LC-OUT-001', + 'first_name' => 'Kofi', + 'last_name' => 'Owusu', + ]); + + $this->seed(AssessmentTemplateSeeder::class); + } + + public function test_outcome_core_seeded(): void + { + $t = AssessmentTemplate::currentSystemByCode('outcome_core'); + $this->assertNotNull($t); + $this->assertSame(AssessmentTemplate::CATEGORY_OUTCOME, $t->category); + $this->assertTrue($t->questions()->where('code', 'quality_of_life')->exists()); + $this->assertTrue($t->questions()->where('code', 'falls_count')->exists()); + } + + public function test_nurse_can_record_outcomes_and_see_trends_on_free_plan(): void + { + $this->actingAs($this->user) + ->post(route('care.outcomes.store', $this->patient)) + ->assertRedirect(); + + $assessment = Assessment::first(); + $this->assertSame('outcome_core', $assessment->template->code); + + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), [ + 'answers' => [ + 'quality_of_life' => 7, + 'pain_score' => 3, + 'falls_count' => 1, + 'hospital_admissions_since_last' => 0, + 'medication_adherence' => 'good', + ], + ]); + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)) + ->assertRedirect(); + + $this->actingAs($this->user) + ->get(route('care.outcomes.index', $this->patient)) + ->assertOk() + ->assertSee('Longitudinal outcomes') + ->assertSee('Quality of life trend') + ->assertSee('7') + ->assertSee('Pain score trend') + ->assertSee('3') + ->assertSee('Enterprise'); // free plan notice + + $this->actingAs($this->user) + ->get(route('care.patients.show', $this->patient)) + ->assertOk() + ->assertSee('Outcomes'); + } + + public function test_second_outcome_builds_series(): void + { + foreach ([[5, 4], [8, 2]] as [$qol, $pain]) { + $this->actingAs($this->user) + ->post(route('care.outcomes.store', $this->patient)) + ->assertRedirect(); + $assessment = Assessment::query()->where('status', Assessment::STATUS_DRAFT)->latest('id')->first(); + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), [ + 'answers' => [ + 'quality_of_life' => $qol, + 'pain_score' => $pain, + ], + ]); + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)); + } + + // Idempotent start when draft exists would reuse — after complete, second store creates new. + $this->assertSame(2, Assessment::where('status', Assessment::STATUS_COMPLETED)->count()); + + $html = $this->actingAs($this->user) + ->get(route('care.outcomes.index', $this->patient)) + ->assertOk() + ->getContent(); + + $this->assertStringContainsString('8', $html); + $this->assertStringContainsString('5', $html); + } +} diff --git a/tests/Feature/CarePathwayTest.php b/tests/Feature/CarePathwayTest.php new file mode 100644 index 0000000..3bef060 --- /dev/null +++ b/tests/Feature/CarePathwayTest.php @@ -0,0 +1,336 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::create([ + 'public_id' => 'pathway-user', + 'name' => 'Pathway User', + 'email' => 'pathway@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Pathway Clinic', + 'slug' => 'pathway-clinic', + 'timezone' => 'UTC', + 'settings' => [ + 'onboarded' => true, + 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true], + ], + ]); + + $this->member = Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'doctor', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'LC-PATH-001', + 'first_name' => 'Kojo', + 'last_name' => 'Stroke', + ]); + } + + public function test_clinical_pathway_seeder_loads_stroke(): void + { + $this->seed(ClinicalPathwaySeeder::class); + + $pathway = ClinicalPathway::findByCode('stroke'); + $this->assertNotNull($pathway); + $this->assertTrue($pathway->is_active); + $this->assertContains('I63', $pathway->match_rules['icd_prefixes'] ?? []); + $this->assertGreaterThanOrEqual(2, $pathway->templates()->count()); + $this->assertTrue($pathway->templates->contains('template_code', 'nihss')); + $this->assertTrue($pathway->templates->contains('template_code', 'mrs')); + $required = $pathway->templates()->where('is_required_on_activation', true)->pluck('template_code')->all(); + $this->assertEqualsCanonicalizing(['nihss', 'mrs'], $required); + } + + public function test_matcher_icd_keyword_exclude_and_no_match(): void + { + $this->seed(ClinicalPathwaySeeder::class); + $matcher = app(PathwayMatcher::class); + + $icd = $matcher->match([['code' => 'I63.9', 'description' => '']]); + $this->assertCount(1, $icd); + $this->assertSame('stroke', $icd[0]['pathway_code']); + $this->assertSame(100, $icd[0]['rank']); + $this->assertStringStartsWith('icd_prefix:', $icd[0]['match_reason']); + + $kw = $matcher->match([['code' => '', 'description' => 'Ischaemic stroke']]); + $this->assertCount(1, $kw); + $this->assertSame(50, $kw[0]['rank']); + $this->assertStringContainsString('keyword:', $kw[0]['match_reason']); + + $tia = $matcher->match([['code' => 'G45.9', 'description' => 'TIA']]); + $this->assertCount(1, $tia); + $this->assertSame('stroke', $tia[0]['pathway_code']); + + $none = $matcher->match([['code' => '', 'description' => 'History unclear']]); + $this->assertCount(0, $none); + + $excluded = $matcher->match([['code' => '', 'description' => 'Family history of stroke']]); + $this->assertCount(0, $excluded); + } + + public function test_activate_is_idempotent_and_creates_required_drafts(): void + { + $this->seed(ClinicalPathwaySeeder::class); + + // Minimal current templates so activation can draft them. + foreach (['nihss' => 'NIHSS', 'mrs' => 'mRS'] as $code => $name) { + $t = AssessmentTemplate::create([ + 'code' => $code, + 'name' => $name, + 'category' => AssessmentTemplate::CATEGORY_DISEASE, + 'version' => 1, + 'is_current' => true, + 'scoring_strategy' => $code === 'mrs' ? 'single_value' : 'sum_items', + 'meta' => ['capture_roles' => ['doctor']], + ]); + AssessmentQuestion::create([ + 'template_id' => $t->id, + 'code' => 'item', + 'label' => 'Item', + 'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM, + 'options' => ['choices' => [['code' => '1', 'label' => '1', 'score' => 1]], 'max' => 1], + 'is_required' => true, + 'sort_order' => 1, + ]); + } + + $visit = Visit::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + + $consultation = Consultation::create([ + 'owner_ref' => $this->user->public_id, + 'visit_id' => $visit->id, + 'patient_id' => $this->patient->id, + 'status' => Consultation::STATUS_DRAFT, + 'started_at' => now(), + ]); + + Diagnosis::create([ + 'owner_ref' => $this->user->public_id, + 'consultation_id' => $consultation->id, + 'code' => 'I63.9', + 'description' => 'Cerebral infarction', + 'is_primary' => true, + ]); + + $this->actingAs($this->user) + ->post(route('care.pathways.store', $this->patient), [ + 'pathway_code' => 'stroke', + 'consultation_uuid' => $consultation->uuid, + ]) + ->assertRedirect(route('care.pathways.index', $this->patient)); + + $this->assertSame(1, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count()); + $pp = PatientPathway::first(); + $this->assertStringContainsString('I63.9', (string) $pp->activation_diagnosis_text); + $this->assertSame($consultation->id, $pp->activation_consultation_id); + $this->assertSame(2, Assessment::where('status', Assessment::STATUS_DRAFT)->count()); + $this->assertTrue(Assessment::where('patient_pathway_id', $pp->id)->exists()); + $this->assertDatabaseHas('care_audit_logs', ['action' => 'pathway.activated']); + + // Idempotent re-activate + $this->actingAs($this->user) + ->post(route('care.pathways.store', $this->patient), [ + 'pathway_code' => 'stroke', + 'consultation_uuid' => $consultation->uuid, + ]) + ->assertRedirect(); + + $this->assertSame(1, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count()); + $this->assertSame(2, Assessment::where('status', Assessment::STATUS_DRAFT)->count()); + } + + public function test_suggestions_use_persisted_diagnoses_only(): void + { + $this->seed(ClinicalPathwaySeeder::class); + + $visit = Visit::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + + $consultation = Consultation::create([ + 'owner_ref' => $this->user->public_id, + 'visit_id' => $visit->id, + 'patient_id' => $this->patient->id, + 'status' => Consultation::STATUS_DRAFT, + 'started_at' => now(), + ]); + + $this->actingAs($this->user) + ->getJson(route('care.consultations.pathway-suggestions', $consultation)) + ->assertOk() + ->assertJsonPath('data', []); + + Diagnosis::create([ + 'owner_ref' => $this->user->public_id, + 'consultation_id' => $consultation->id, + 'code' => null, + 'description' => 'Ischaemic stroke', + 'is_primary' => true, + ]); + + $this->actingAs($this->user) + ->getJson(route('care.consultations.pathway-suggestions', $consultation)) + ->assertOk() + ->assertJsonPath('data.0.pathway_code', 'stroke') + ->assertJsonPath('data.0.already_active', false); + + $this->actingAs($this->user) + ->get(route('care.consultations.show', $consultation)) + ->assertOk() + ->assertSee('Clinical pathways') + ->assertSee('Stroke') + ->assertSee('keyword:'); + } + + public function test_deactivate_pathway(): void + { + $this->seed(ClinicalPathwaySeeder::class); + $pathway = ClinicalPathway::findByCode('stroke'); + + $pp = app(PathwayService::class)->activate( + $this->patient, + $pathway, + $this->user->public_id, + $this->member, + ['actor' => $this->user->public_id, 'activation_diagnosis_text' => 'stroke'], + ); + + $this->actingAs($this->user) + ->post(route('care.pathways.deactivate', [$this->patient, $pp])) + ->assertRedirect(); + + $this->assertSame(PatientPathway::STATUS_INACTIVE, $pp->fresh()->status); + $this->assertDatabaseHas('care_audit_logs', ['action' => 'pathway.deactivated']); + } + + public function test_nurse_cannot_activate_pathway(): void + { + $this->seed(ClinicalPathwaySeeder::class); + $this->member->update(['role' => 'nurse']); + + $this->actingAs($this->user) + ->post(route('care.pathways.store', $this->patient), [ + 'pathway_code' => 'stroke', + ]) + ->assertForbidden(); + } + + public function test_template_version_bump_still_resolves_pathway_binding(): void + { + $this->seed(ClinicalPathwaySeeder::class); + + AssessmentTemplate::create([ + 'code' => 'nihss', + 'name' => 'NIHSS v1', + 'category' => AssessmentTemplate::CATEGORY_DISEASE, + 'version' => 1, + 'is_current' => false, + 'meta' => ['capture_roles' => ['doctor']], + ]); + + $v2 = AssessmentTemplate::create([ + 'code' => 'nihss', + 'name' => 'NIHSS v2', + 'category' => AssessmentTemplate::CATEGORY_DISEASE, + 'version' => 2, + 'is_current' => true, + 'meta' => ['capture_roles' => ['doctor']], + ]); + + AssessmentQuestion::create([ + 'template_id' => $v2->id, + 'code' => 'item', + 'label' => 'Item', + 'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM, + 'options' => ['choices' => [['code' => '1', 'label' => '1', 'score' => 1]]], + 'is_required' => true, + 'sort_order' => 1, + ]); + + // Only nihss required for this test — remove mrs binding temporarily via pathway seed already has both. + // Activate stroke; mrs missing is skipped; nihss v2 should draft. + $pathway = ClinicalPathway::findByCode('stroke'); + $pp = app(PathwayService::class)->activate( + $this->patient, + $pathway, + $this->user->public_id, + $this->member, + ['actor' => $this->user->public_id], + ); + + $draft = Assessment::where('patient_pathway_id', $pp->id)->where('template_id', $v2->id)->first(); + $this->assertNotNull($draft); + $this->assertSame(2, $draft->template->version); + } +} diff --git a/tests/Feature/CareStrokeExtendedTest.php b/tests/Feature/CareStrokeExtendedTest.php new file mode 100644 index 0000000..f74526d --- /dev/null +++ b/tests/Feature/CareStrokeExtendedTest.php @@ -0,0 +1,178 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::create([ + 'public_id' => 'stroke-ext-user', + 'name' => 'Stroke Ext', + 'email' => 'stroke-ext@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Ext Clinic', + 'slug' => 'ext-clinic', + 'timezone' => 'UTC', + 'settings' => [ + 'onboarded' => true, + 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true], + ], + ]); + + Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'doctor', + ]); + + Branch::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'patient_number' => 'LC-SEXT-001', + 'first_name' => 'Yaw', + 'last_name' => 'Mensah', + ]); + + $this->seed(AssessmentTemplateSeeder::class); + $this->seed(ClinicalPathwaySeeder::class); + } + + public function test_stroke_pathway_includes_extended_optional_instruments(): void + { + $pathway = ClinicalPathway::findByCode('stroke'); + $codes = $pathway->templates()->pluck('template_code')->all(); + + $this->assertContains('barthel', $codes); + $this->assertContains('gcs', $codes); + $this->assertContains('stroke_swallow', $codes); + $this->assertContains('stroke_clinical', $codes); + + $required = $pathway->templates()->where('is_required_on_activation', true)->pluck('template_code')->all(); + $this->assertEqualsCanonicalizing(['nihss', 'mrs'], $required); + } + + public function test_gcs_golden_score_e4_v5_m6_is_fifteen(): void + { + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'gcs']) + ->assertRedirect(); + + $assessment = Assessment::first(); + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), [ + 'answers' => ['eye' => '4', 'verbal' => '5', 'motor' => '6'], + ]); + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)) + ->assertRedirect(); + + $score = AssessmentScore::first(); + $this->assertEquals(15, (float) $score->total_score); + $this->assertEquals(15, (float) $score->max_score); + } + + public function test_barthel_fully_independent_is_one_hundred(): void + { + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'barthel']) + ->assertRedirect(); + + $assessment = Assessment::first(); + $answers = [ + 'feeding' => '10', + 'bathing' => '5', + 'grooming' => '5', + 'dressing' => '10', + 'bowels' => '10', + 'bladder' => '10', + 'toilet' => '10', + 'transfers' => '15', + 'mobility' => '15', + 'stairs' => '10', + ]; + + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), ['answers' => $answers]); + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)) + ->assertRedirect(); + + $this->assertEquals(100, (float) AssessmentScore::first()->total_score); + $this->assertEquals(100, (float) AssessmentScore::first()->max_score); + } + + public function test_stroke_clinical_records_tia_subtype(): void + { + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'stroke_clinical']) + ->assertRedirect(); + + $assessment = Assessment::first(); + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), [ + 'answers' => [ + 'stroke_subtype' => 'tia', + 'thrombolysis_status' => 'not_indicated', + ], + ]); + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)) + ->assertRedirect(); + + $this->assertTrue($assessment->fresh()->isCompleted()); + $this->assertSame(0, AssessmentScore::count()); // no scoring strategy + $subtype = $assessment->fresh()->answers->first( + fn ($a) => $a->question?->code === 'stroke_subtype' + ); + $this->assertSame('tia', $subtype->value_text); + } + + public function test_all_extended_templates_seeded(): void + { + foreach (['barthel', 'gcs', 'stroke_swallow', 'stroke_clinical'] as $code) { + $this->assertNotNull(AssessmentTemplate::currentSystemByCode($code), $code); + } + } +} diff --git a/tests/Feature/CareStrokeMvpTest.php b/tests/Feature/CareStrokeMvpTest.php new file mode 100644 index 0000000..97b000d --- /dev/null +++ b/tests/Feature/CareStrokeMvpTest.php @@ -0,0 +1,289 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::create([ + 'public_id' => 'stroke-mvp-user', + 'name' => 'Stroke MVP', + 'email' => 'stroke@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Stroke Clinic', + 'slug' => 'stroke-clinic', + 'timezone' => 'UTC', + 'settings' => [ + 'onboarded' => true, + 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true], + ], + ]); + + $this->member = Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'doctor', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'LC-STROKE-001', + 'first_name' => 'Efua', + 'last_name' => 'Asante', + ]); + + $this->seed(AssessmentTemplateSeeder::class); + $this->seed(ClinicalPathwaySeeder::class); + } + + public function test_seed_packs_load_nihss_and_mrs(): void + { + $nihss = AssessmentTemplate::currentSystemByCode('nihss'); + $mrs = AssessmentTemplate::currentSystemByCode('mrs'); + + $this->assertNotNull($nihss); + $this->assertNotNull($mrs); + $this->assertSame('sum_items', $nihss->scoring_strategy); + $this->assertSame('single_value', $mrs->scoring_strategy); + $this->assertSame(15, $nihss->questions()->count()); + $this->assertSame(1, $mrs->questions()->count()); + $this->assertSame(['doctor'], $nihss->meta['capture_roles'] ?? null); + + $pathway = ClinicalPathway::findByCode('stroke'); + $this->assertNotNull($pathway); + $codes = $pathway->templates()->pluck('template_code')->all(); + $this->assertContains('nihss', $codes); + $this->assertContains('mrs', $codes); + $required = $pathway->templates()->where('is_required_on_activation', true)->pluck('template_code')->all(); + $this->assertEqualsCanonicalizing(['nihss', 'mrs'], $required); + } + + public function test_golden_mrs_score_three(): void + { + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'mrs']) + ->assertRedirect(); + + $assessment = Assessment::first(); + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), [ + 'answers' => ['mrs_score' => '3'], + ]) + ->assertRedirect(); + + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)) + ->assertRedirect(); + + $score = AssessmentScore::where('assessment_id', $assessment->id)->first(); + $this->assertNotNull($score); + $this->assertEquals(3, (float) $score->total_score); + $this->assertEquals(6, (float) $score->max_score); + $this->assertSame('mrs', $score->template_code); + } + + public function test_golden_nihss_fixture_total_twelve_max_forty_two(): void + { + // Moderate left hemisphere pattern → total 12; max 42. + $answers = [ + '1a_loc' => '1', + '1b_loc_questions' => '1', + '1c_loc_commands' => '0', + '2_gaze' => '0', + '3_visual' => '1', + '4_facial' => '2', + '5a_motor_arm_left' => '2', + '5b_motor_arm_right' => '0', + '6a_motor_leg_left' => '2', + '6b_motor_leg_right' => '0', + '7_ataxia' => '0', + '8_sensory' => '1', + '9_language' => '1', + '10_dysarthria' => '1', + '11_extinction' => '0', + ]; + // 1+1+0+0+1+2+2+0+2+0+0+1+1+1+0 = 12 + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss']) + ->assertRedirect(); + + $assessment = Assessment::first(); + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), ['answers' => $answers]) + ->assertRedirect(); + + $preview = app(ScoringService::class)->preview( + $assessment->fresh(['template.questions', 'answers.question']) + ); + $this->assertEquals(12.0, $preview['total']); + $this->assertEquals(42.0, $preview['max']); + + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)) + ->assertRedirect(); + + $score = AssessmentScore::first(); + $this->assertEquals(12, (float) $score->total_score); + $this->assertEquals(42, (float) $score->max_score); + $this->assertArrayHasKey('loc', $score->subscores); + $this->assertEquals(2.0, (float) $score->subscores['loc']); // 1a+1b+1c = 2 + } + + public function test_m2_e2e_activate_stroke_complete_nihss_and_mrs(): void + { + $visit = Visit::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + + $consultation = Consultation::create([ + 'owner_ref' => $this->user->public_id, + 'visit_id' => $visit->id, + 'patient_id' => $this->patient->id, + 'status' => Consultation::STATUS_DRAFT, + 'started_at' => now(), + ]); + + Diagnosis::create([ + 'owner_ref' => $this->user->public_id, + 'consultation_id' => $consultation->id, + 'code' => 'I63.9', + 'description' => 'Cerebral infarction, unspecified', + 'is_primary' => true, + ]); + + $this->actingAs($this->user) + ->get(route('care.consultations.show', $consultation)) + ->assertOk() + ->assertSee('Stroke') + ->assertSee('Activate'); + + $this->actingAs($this->user) + ->post(route('care.pathways.store', $this->patient), [ + 'pathway_code' => 'stroke', + 'consultation_uuid' => $consultation->uuid, + ]) + ->assertRedirect(); + + $this->assertSame(1, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count()); + $this->assertSame(2, Assessment::where('status', Assessment::STATUS_DRAFT)->count()); + + $this->actingAs($this->user) + ->get(route('care.consultations.show', $consultation)) + ->assertOk() + ->assertSee('Pathway instruments') + ->assertSee('NIH Stroke Scale') + ->assertSee('Modified Rankin Scale') + ->assertSee('Continue'); + + $nihss = Assessment::query() + ->whereHas('template', fn ($q) => $q->where('code', 'nihss')) + ->first(); + $mrs = Assessment::query() + ->whereHas('template', fn ($q) => $q->where('code', 'mrs')) + ->first(); + + $this->assertNotNull($nihss); + $this->assertNotNull($mrs); + $this->assertSame($consultation->id, $nihss->consultation_id); + + // Minimal valid NIHSS (all zeros) → total 0. + $zeroAnswers = collect($nihss->template->questions)->mapWithKeys( + fn ($q) => [$q->code => '0'] + )->all(); + + $this->actingAs($this->user) + ->put(route('care.assessments.update', $nihss), ['answers' => $zeroAnswers]) + ->assertRedirect(); + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $nihss)) + ->assertRedirect(); + + $this->assertEquals(0, (float) AssessmentScore::where('assessment_id', $nihss->id)->value('total_score')); + $this->assertEquals(42, (float) AssessmentScore::where('assessment_id', $nihss->id)->value('max_score')); + + $this->actingAs($this->user) + ->put(route('care.assessments.update', $mrs), ['answers' => ['mrs_score' => '2']]) + ->assertRedirect(); + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $mrs)) + ->assertRedirect(); + + $this->assertEquals(2, (float) AssessmentScore::where('assessment_id', $mrs->id)->value('total_score')); + + $this->actingAs($this->user) + ->get(route('care.consultations.show', $consultation)) + ->assertOk() + ->assertSee('score 0') + ->assertSee('score 2'); + } + + public function test_nurse_cannot_start_seeded_nihss(): void + { + $this->member->update(['role' => 'nurse']); + + $this->actingAs($this->user) + ->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss']) + ->assertForbidden(); + } +} diff --git a/tests/Feature/CareUniversalIntakeTest.php b/tests/Feature/CareUniversalIntakeTest.php new file mode 100644 index 0000000..34af6aa --- /dev/null +++ b/tests/Feature/CareUniversalIntakeTest.php @@ -0,0 +1,199 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::create([ + 'public_id' => 'universal-intake-user', + 'name' => 'Intake User', + 'email' => 'intake@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Intake Clinic', + 'slug' => 'intake-clinic', + 'timezone' => 'UTC', + 'settings' => [ + 'onboarded' => true, + 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true], + ], + ]); + + $this->member = Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'nurse', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $this->patient = Patient::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'LC-INTAKE-001', + 'first_name' => 'Kwame', + 'last_name' => 'Boateng', + ]); + } + + public function test_assessment_template_seeder_loads_universal_intake_pack(): void + { + $this->seed(AssessmentTemplateSeeder::class); + + $template = AssessmentTemplate::currentSystemByCode('universal_intake'); + $this->assertNotNull($template); + $this->assertSame(1, $template->version); + $this->assertSame(AssessmentTemplate::CATEGORY_UNIVERSAL, $template->category); + $this->assertNull($template->organization_id); + $this->assertSame(['doctor', 'nurse'], $template->meta['capture_roles'] ?? null); + + $codes = $template->questions()->pluck('code')->all(); + $this->assertContains('chief_complaint', $codes); + $this->assertContains('hpi', $codes); + $this->assertContains('pain_score', $codes); + $this->assertContains('smoking_status', $codes); + $this->assertContains('mobility', $codes); + $this->assertContains('baseline_labs_summary', $codes); + + $chief = $template->questions()->where('code', 'chief_complaint')->first(); + $this->assertTrue($chief->is_required); + $this->assertSame(AssessmentQuestion::TYPE_TEXTAREA, $chief->answer_type); + } + + public function test_seeder_is_idempotent_and_replaces_questions(): void + { + $this->seed(AssessmentTemplateSeeder::class); + $firstId = AssessmentTemplate::currentSystemByCode('universal_intake')->id; + $questionCount = AssessmentQuestion::where('template_id', $firstId)->count(); + + $this->seed(AssessmentTemplateSeeder::class); + + $this->assertSame(1, AssessmentTemplate::where('code', 'universal_intake')->count()); + $this->assertSame($firstId, AssessmentTemplate::currentSystemByCode('universal_intake')->id); + $this->assertSame($questionCount, AssessmentQuestion::where('template_id', $firstId)->count()); + } + + public function test_nurse_can_complete_seeded_universal_intake_on_consultation(): void + { + $this->seed(AssessmentTemplateSeeder::class); + + $visit = Visit::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $this->patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + + $consultation = Consultation::create([ + 'owner_ref' => $this->user->public_id, + 'visit_id' => $visit->id, + 'patient_id' => $this->patient->id, + 'status' => Consultation::STATUS_DRAFT, + 'started_at' => now(), + 'symptoms' => 'Free-text headache narrative', + ]); + + // Nurse has vitals + assessments.capture; free-text symptoms fields require consultations.manage. + $this->actingAs($this->user) + ->get(route('care.consultations.show', $consultation)) + ->assertOk() + ->assertSee('Universal intake') + ->assertSee('narrative source of truth', false) + ->assertSee('Start universal intake'); + + $this->actingAs($this->user) + ->post(route('care.consultations.assessments.store', $consultation), [ + 'template_code' => 'universal_intake', + ]) + ->assertRedirect(); + + $assessment = Assessment::first(); + $this->assertNotNull($assessment); + $this->assertSame($consultation->id, $assessment->consultation_id); + $this->assertSame('universal_intake', $assessment->template->code); + + $this->actingAs($this->user) + ->put(route('care.assessments.update', $assessment), [ + 'answers' => [ + 'chief_complaint' => 'Severe headache for 2 days', + 'pain_score' => 7, + 'smoking_status' => 'never', + 'mobility' => 'independent', + ], + ]) + ->assertRedirect(); + + $this->actingAs($this->user) + ->post(route('care.assessments.complete', $assessment)) + ->assertRedirect(); + + $assessment->refresh(); + $this->assertTrue($assessment->isCompleted()); + + // Dual narrative: free-text symptoms unchanged by structured intake. + $this->assertSame('Free-text headache narrative', $consultation->fresh()->symptoms); + + $this->actingAs($this->user) + ->get(route('care.patients.show', $this->patient)) + ->assertOk() + ->assertSee('Universal intake') + ->assertSee('Severe headache for 2 days'); + } + + public function test_create_form_lists_seeded_universal_for_nurse(): void + { + $this->seed(AssessmentTemplateSeeder::class); + + $this->actingAs($this->user) + ->get(route('care.assessments.create', $this->patient)) + ->assertOk() + ->assertSee('Universal Intake') + ->assertSee('universal_intake'); + } +}