diff --git a/.env.example b/.env.example index 94b9190..1cb7916 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,9 @@ SERVICE_EVENTS_INBOUND_SECRET= DEMO_ACCOUNTS_ENABLED=false DEMO_RESET_ON_LOGIN=true +# Care Device Agent (local serial/BLE helper) — see docs/devices.md +# Tokens are issued per device in Settings → Devices (not env vars). + # --- Inbound service API keys (sibling Ladill apps calling Care) --- CARE_API_KEY_FRONTDESK= CARE_API_KEY_CRM= diff --git a/README.md b/README.md index 11843ca..f91220c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ clinical record is scoped to a platform account (`owner_ref`) and organization. - **Billing** — visit invoices, line items, cash/MoMo payments, and online checkout via your own Paystack / Flutterwave / Hubtel (Pro) - **Reports** — patients, appointments, lab, finance, clinical (CSV export) - **Admin** — branches, departments, practitioners, members, audit log +- **Devices** — barcode wedge scanners (all plans), agent-connected vitals/lab hardware (Pro); see `docs/devices.md` ## Platform integration diff --git a/app/Http/Controllers/Api/DeviceAgentController.php b/app/Http/Controllers/Api/DeviceAgentController.php new file mode 100644 index 0000000..dd3121f --- /dev/null +++ b/app/Http/Controllers/Api/DeviceAgentController.php @@ -0,0 +1,165 @@ +attributes->get('care.device'); + + $validated = $request->validate([ + 'agent_version' => ['nullable', 'string', 'max:50'], + 'hostname' => ['nullable', 'string', 'max:255'], + 'meta' => ['nullable', 'array'], + ]); + + $this->devices->recordHeartbeat($device, $validated); + + return response()->json([ + 'status' => 'active', + 'device' => [ + 'id' => $device->id, + 'name' => $device->name, + 'type' => $device->type, + 'branch_id' => $device->branch_id, + ], + 'last_seen_at' => $device->fresh()->last_seen_at?->toIso8601String(), + ]); + } + + public function storeVitals(Request $request): JsonResponse + { + /** @var Device $device */ + $device = $request->attributes->get('care.device'); + + $validated = $request->validate([ + 'consultation_uuid' => ['nullable', 'uuid'], + 'visit_uuid' => ['nullable', 'uuid'], + 'vitals' => ['required', 'array'], + 'vitals.bp_systolic' => ['nullable', 'integer', 'min:40', 'max:300'], + 'vitals.bp_diastolic' => ['nullable', 'integer', 'min:20', 'max:200'], + 'vitals.pulse' => ['nullable', 'integer', 'min:20', 'max:300'], + 'vitals.temperature' => ['nullable', 'numeric', 'min:30', 'max:45'], + 'vitals.weight_kg' => ['nullable', 'numeric', 'min:0', 'max:500'], + 'vitals.height_cm' => ['nullable', 'numeric', 'min:0', 'max:300'], + 'vitals.spo2' => ['nullable', 'integer', 'min:50', 'max:100'], + 'vitals.respiratory_rate' => ['nullable', 'integer', 'min:5', 'max:80'], + 'raw' => ['nullable', 'array'], + ]); + + abort_unless( + filled($validated['consultation_uuid'] ?? null) || filled($validated['visit_uuid'] ?? null), + 422, + 'consultation_uuid or visit_uuid is required.', + ); + + $consultation = $this->resolveConsultation($device, $validated); + abort_unless($consultation, 404, 'Consultation not found for this device scope.'); + + $vital = $this->consultations->saveVitals( + $consultation, + $device->owner_ref, + $validated['vitals'], + 'device:'.$device->id, + [ + 'source' => VitalSign::SOURCE_DEVICE, + 'device_id' => $device->id, + 'raw_payload' => $validated['raw'] ?? $request->all(), + ], + ); + + $this->devices->recordHeartbeat($device); + + return response()->json([ + 'vital_sign_id' => $vital?->id, + 'consultation_uuid' => $consultation->uuid, + 'source' => VitalSign::SOURCE_DEVICE, + 'device_id' => $device->id, + ], 201); + } + + public function collectSample(Request $request): JsonResponse + { + /** @var Device $device */ + $device = $request->attributes->get('care.device'); + + $validated = $request->validate([ + 'investigation_uuid' => ['required', 'uuid'], + 'sample_barcode' => ['nullable', 'string', 'max:100'], + ]); + + $investigation = InvestigationRequest::query() + ->where('uuid', $validated['investigation_uuid']) + ->where('organization_id', $device->organization_id) + ->when($device->branch_id, fn ($q) => $q->where('branch_id', $device->branch_id)) + ->first(); + + abort_unless($investigation, 404, 'Investigation not found for this device scope.'); + + $updated = $this->investigations->collectSample( + $investigation, + $device->owner_ref, + $validated['sample_barcode'] ?? null, + 'device:'.$device->id, + ); + + $this->devices->recordHeartbeat($device); + + return response()->json([ + 'investigation_uuid' => $updated->uuid, + 'status' => $updated->status, + 'sample_barcode' => $updated->sample_barcode, + ]); + } + + /** + * @param array $validated + */ + protected function resolveConsultation(Device $device, array $validated): ?Consultation + { + if (! empty($validated['consultation_uuid'])) { + return Consultation::query() + ->where('uuid', $validated['consultation_uuid']) + ->whereHas('visit', function ($q) use ($device) { + $q->where('organization_id', $device->organization_id) + ->when($device->branch_id, fn ($inner) => $inner->where('branch_id', $device->branch_id)); + }) + ->first(); + } + + $visit = Visit::query() + ->where('uuid', $validated['visit_uuid']) + ->where('organization_id', $device->organization_id) + ->when($device->branch_id, fn ($q) => $q->where('branch_id', $device->branch_id)) + ->first(); + + if (! $visit) { + return null; + } + + return Consultation::query() + ->where('visit_id', $visit->id) + ->orderByDesc('id') + ->first(); + } +} diff --git a/app/Http/Controllers/Care/DeviceController.php b/app/Http/Controllers/Care/DeviceController.php new file mode 100644 index 0000000..7fda634 --- /dev/null +++ b/app/Http/Controllers/Care/DeviceController.php @@ -0,0 +1,255 @@ +authorizeAbility($request, 'devices.view'); + $organization = $this->organization($request); + $branchScope = app(OrganizationResolver::class)->branchScope($this->member($request)); + + $query = Device::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->with('branch') + ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) + ->orderBy('name'); + + $devices = (clone $query)->paginate(25); + + $heroStats = [ + 'total' => (clone $query)->count(), + 'online' => (clone $query) + ->where('status', Device::STATUS_ACTIVE) + ->where('last_seen_at', '>=', now()->subMinutes(10)) + ->count(), + 'agent' => (clone $query)->where('connection_mode', Device::MODE_AGENT)->count(), + ]; + + return view('care.devices.index', [ + 'organization' => $organization, + 'devices' => $devices, + 'deviceTypes' => config('care.device_types'), + 'connectionModes' => config('care.device_connection_modes'), + 'heroStats' => $heroStats, + 'canManage' => app(\App\Services\Care\CarePermissions::class) + ->can($this->member($request), 'devices.manage'), + 'hasAgentDevices' => $this->plans->hasPaidPlan($organization), + ]); + } + + public function create(Request $request): View + { + $this->authorizeAbility($request, 'devices.manage'); + $organization = $this->organization($request); + + return view('care.devices.create', [ + 'organization' => $organization, + 'deviceTypes' => config('care.device_types'), + 'connectionModes' => config('care.device_connection_modes'), + 'branches' => $this->branches($request, $organization->id), + 'hasAgentDevices' => $this->plans->hasPaidPlan($organization), + 'agentTypes' => config('care.device_agent_types'), + 'defaultModes' => collect(config('care.device_types')) + ->keys() + ->mapWithKeys(fn ($type) => [$type => $this->devices->defaultConnectionMode($type)]) + ->all(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'devices.manage'); + $organization = $this->organization($request); + + $validated = $this->validatedDevice($request, $organization); + + if (! $this->devices->canRegisterType($organization, $validated['type'], $this->plans)) { + return back()->withInput()->with( + 'error', + 'Agent-connected clinical devices require Care Pro or Enterprise. USB barcode/QR scanners work on Free.', + ); + } + + $mode = $validated['connection_mode'] + ?? $this->devices->defaultConnectionMode($validated['type']); + + $device = Device::create([ + 'owner_ref' => $this->ownerRef($request), + 'organization_id' => $organization->id, + 'branch_id' => $validated['branch_id'] ?? null, + 'name' => $validated['name'], + 'type' => $validated['type'], + 'connection_mode' => $mode, + 'status' => Device::STATUS_OFFLINE, + 'metadata' => [], + ]); + + $plainToken = null; + if ($mode === Device::MODE_AGENT || in_array($validated['type'], config('care.device_agent_types', []), true)) { + $plainToken = $this->devices->issueToken($device); + } + + $redirect = redirect()->route('care.devices.edit', $device) + ->with('success', 'Device registered.'); + + if ($plainToken) { + $redirect->with('device_token', $plainToken); + } + + return $redirect; + } + + public function edit(Request $request, Device $device): View + { + $this->authorizeAbility($request, 'devices.manage'); + $this->authorizeDevice($request, $device); + + return view('care.devices.edit', [ + 'device' => $device, + 'organization' => $this->organization($request), + 'deviceTypes' => config('care.device_types'), + 'connectionModes' => config('care.device_connection_modes'), + 'deviceStatuses' => config('care.device_statuses'), + 'branches' => $this->branches($request, $device->organization_id), + 'hasAgentDevices' => $this->plans->hasPaidPlan($this->organization($request)), + 'agentTypes' => config('care.device_agent_types'), + 'connectionHint' => $this->devices->connectionHint($device->type, $device->connection_mode), + 'plainToken' => session('device_token'), + ]); + } + + public function update(Request $request, Device $device): RedirectResponse + { + $this->authorizeAbility($request, 'devices.manage'); + $this->authorizeDevice($request, $device); + $organization = $this->organization($request); + + $validated = $this->validatedDevice($request, $organization, updating: true); + + if ( + isset($validated['type']) + && $validated['type'] !== $device->type + && ! $this->devices->canRegisterType($organization, $validated['type'], $this->plans) + ) { + return back()->withInput()->with( + 'error', + 'Agent-connected clinical devices require Care Pro or Enterprise.', + ); + } + + $device->update([ + 'name' => $validated['name'], + 'type' => $validated['type'], + 'branch_id' => $validated['branch_id'] ?? null, + 'connection_mode' => $validated['connection_mode'] + ?? $device->connection_mode + ?? $this->devices->defaultConnectionMode($validated['type']), + 'status' => $validated['status'] ?? $device->status, + ]); + + return redirect()->route('care.devices.index')->with('success', 'Device updated.'); + } + + public function destroy(Request $request, Device $device): RedirectResponse + { + $this->authorizeAbility($request, 'devices.manage'); + $this->authorizeDevice($request, $device); + $device->delete(); + + return redirect()->route('care.devices.index')->with('success', 'Device removed.'); + } + + public function regenerateToken(Request $request, Device $device): RedirectResponse + { + $this->authorizeAbility($request, 'devices.manage'); + $this->authorizeDevice($request, $device); + + $plain = $this->devices->issueToken($device); + + return back() + ->with('success', 'Device token regenerated. Copy it now — it will not be shown again.') + ->with('device_token', $plain); + } + + public function revokeToken(Request $request, Device $device): RedirectResponse + { + $this->authorizeAbility($request, 'devices.manage'); + $this->authorizeDevice($request, $device); + $this->devices->revokeToken($device); + + return back()->with('success', 'Device token revoked.'); + } + + protected function authorizeDevice(Request $request, Device $device): void + { + $this->authorizeOwner($request, $device); + abort_unless($device->organization_id === $this->organization($request)->id, 404); + + $branchId = app(OrganizationResolver::class)->branchScope($this->member($request)); + if ($branchId !== null && $device->branch_id !== null && $device->branch_id !== $branchId) { + abort(404); + } + } + + /** + * @return array + */ + protected function validatedDevice(Request $request, $organization, bool $updating = false): array + { + $rules = [ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.device_types')))], + 'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'], + 'connection_mode' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('care.device_connection_modes')))], + ]; + + if ($updating) { + $rules['status'] = ['nullable', 'string', 'in:'.implode(',', array_keys(config('care.device_statuses')))]; + } + + $validated = $request->validate($rules); + + if (! empty($validated['branch_id'])) { + $ok = Branch::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->where('id', $validated['branch_id']) + ->exists(); + abort_unless($ok, 422); + } + + return $validated; + } + + /** @return \Illuminate\Database\Eloquent\Collection */ + protected function branches(Request $request, int $organizationId) + { + $branchScope = app(OrganizationResolver::class)->branchScope($this->member($request)); + + return Branch::owned($this->ownerRef($request)) + ->where('organization_id', $organizationId) + ->where('is_active', true) + ->when($branchScope, fn ($q) => $q->where('id', $branchScope)) + ->orderBy('name') + ->get(); + } +} diff --git a/app/Http/Controllers/Care/PatientController.php b/app/Http/Controllers/Care/PatientController.php index c2cf7df..805e777 100644 --- a/app/Http/Controllers/Care/PatientController.php +++ b/app/Http/Controllers/Care/PatientController.php @@ -10,6 +10,7 @@ use App\Services\Care\CareFeatures; use App\Services\Care\CarePermissions; use App\Services\Care\OrganizationResolver; use App\Services\Care\PatientService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -49,6 +50,58 @@ class PatientController extends Controller return view('care.patients.index', compact('patients', 'organization', 'heroStats')); } + public function scanLookup(Request $request): JsonResponse|RedirectResponse + { + $this->authorizeAbility($request, 'patients.view'); + $organization = $this->organization($request); + $branchScope = app(OrganizationResolver::class)->branchScope($this->member($request)); + + $code = trim((string) $request->query('code', '')); + abort_unless($code !== '', 422, 'Scan code required.'); + + $patient = $this->patients->findByScanCode( + $this->ownerRef($request), + $organization->id, + $code, + $branchScope, + ); + + if ($request->expectsJson() || $request->wantsJson() || $request->ajax()) { + if (! $patient) { + return response()->json(['message' => 'No patient found for that barcode.'], 404); + } + + return response()->json([ + 'patient' => [ + 'uuid' => $patient->uuid, + 'patient_number' => $patient->patient_number, + 'name' => $patient->fullName(), + 'url' => route('care.patients.show', $patient), + ], + ]); + } + + if (! $patient) { + return redirect() + ->route('care.patients.index', ['q' => $code]) + ->with('error', 'No patient found for that barcode.'); + } + + return redirect()->route('care.patients.show', $patient); + } + + public function label(Request $request, Patient $patient): View + { + $this->authorizeAbility($request, 'patients.view'); + $this->authorizePatient($request, $patient); + + return view('care.patients.label', [ + 'patient' => $patient, + 'organization' => $this->organization($request), + 'genders' => config('care.genders'), + ]); + } + public function create(Request $request): View { $this->authorizeAbility($request, 'patients.manage'); diff --git a/app/Http/Controllers/Care/SettingsController.php b/app/Http/Controllers/Care/SettingsController.php index 712c9df..f7ce792 100644 --- a/app/Http/Controllers/Care/SettingsController.php +++ b/app/Http/Controllers/Care/SettingsController.php @@ -54,7 +54,9 @@ class SettingsController extends Controller 'branchCount' => $branchCount, 'canViewBranches' => $permissions->can($member, 'admin.branches.view'), 'canViewTeam' => $permissions->can($member, 'admin.members.view'), + 'canViewDevices' => $permissions->can($member, 'devices.view'), 'hasBranchesFeature' => $plans->hasFeature($organization, 'branches'), + 'hasClinicalDevicesFeature' => $plans->hasFeature($organization, 'clinical_devices'), 'canUseQueueIntegration' => $plans->canUseQueueIntegration($organization), 'assessmentsEngineEnabled' => $careFeatures->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE), 'messagingSmsReady' => $suiteSmsReady || $credential->hasValidSms(), diff --git a/app/Http/Middleware/AuthenticateCareDevice.php b/app/Http/Middleware/AuthenticateCareDevice.php new file mode 100644 index 0000000..cd17494 --- /dev/null +++ b/app/Http/Middleware/AuthenticateCareDevice.php @@ -0,0 +1,52 @@ +extractToken($request); + + abort_unless(is_string($token) && $token !== '', 401, 'Device token required.'); + + $device = $this->devices->findByToken($token); + abort_unless($device, 401, 'Invalid device token.'); + + $request->attributes->set('care.device', $device); + + return $next($request); + } + + protected function extractToken(Request $request): ?string + { + $header = $request->header('X-Care-Device-Token'); + if (is_string($header) && $header !== '') { + return $header; + } + + // Compatibility with Frontdesk-style header. + $legacy = $request->header('X-Device-Token'); + if (is_string($legacy) && $legacy !== '') { + return $legacy; + } + + $bearer = $request->bearerToken(); + if (is_string($bearer) && $bearer !== '') { + return $bearer; + } + + $input = $request->input('token'); + + return is_string($input) && $input !== '' ? $input : null; + } +} diff --git a/app/Models/Device.php b/app/Models/Device.php new file mode 100644 index 0000000..9851cce --- /dev/null +++ b/app/Models/Device.php @@ -0,0 +1,80 @@ + 'array', + 'last_seen_at' => 'datetime', + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function branch(): BelongsTo + { + return $this->belongsTo(Branch::class, 'branch_id'); + } + + public function vitalSigns(): HasMany + { + return $this->hasMany(VitalSign::class, 'device_id'); + } + + public function isOnline(int $staleMinutes = 10): bool + { + return $this->status === self::STATUS_ACTIVE + && $this->last_seen_at + && $this->last_seen_at->gte(now()->subMinutes($staleMinutes)); + } + + public function requiresAgent(): bool + { + return $this->connection_mode === self::MODE_AGENT + || in_array($this->type, config('care.device_agent_types', []), true); + } + + public function hasToken(): bool + { + return filled($this->device_token_hash); + } +} diff --git a/app/Models/VitalSign.php b/app/Models/VitalSign.php index 690a6e4..b3852e3 100644 --- a/app/Models/VitalSign.php +++ b/app/Models/VitalSign.php @@ -12,10 +12,16 @@ class VitalSign extends Model protected $table = 'care_vital_signs'; + public const SOURCE_MANUAL = 'manual'; + + public const SOURCE_DEVICE = 'device'; + + public const SOURCE_IMPORT = 'import'; + protected $fillable = [ 'owner_ref', 'consultation_id', 'bp_systolic', 'bp_diastolic', 'pulse', 'temperature', 'weight_kg', 'height_cm', 'spo2', 'respiratory_rate', - 'recorded_by', 'recorded_at', + 'recorded_by', 'recorded_at', 'source', 'device_id', 'raw_payload', ]; protected function casts(): array @@ -25,6 +31,7 @@ class VitalSign extends Model 'weight_kg' => 'decimal:2', 'height_cm' => 'decimal:1', 'recorded_at' => 'datetime', + 'raw_payload' => 'array', ]; } @@ -32,4 +39,9 @@ class VitalSign extends Model { return $this->belongsTo(Consultation::class, 'consultation_id'); } + + public function device(): BelongsTo + { + return $this->belongsTo(Device::class, 'device_id'); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d6be4ca..7c0f4ba 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,7 +7,10 @@ use App\Listeners\PlatformServiceEventListener; use App\Models\User; use App\Services\Care\OrganizationResolver; use App\Services\Care\PlanService; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; @@ -22,6 +25,15 @@ class AppServiceProvider extends ServiceProvider { Event::listen(ServiceEventOccurred::class, PlatformServiceEventListener::class); + RateLimiter::for('care-device', function (Request $request) { + $token = $request->header('X-Care-Device-Token') + ?? $request->header('X-Device-Token') + ?? $request->bearerToken() + ?? $request->ip(); + + return Limit::perMinute(60)->by((string) $token); + }); + View::composer([ 'partials.sidebar', 'partials.topbar-desktop-widgets', diff --git a/app/Services/Care/CarePermissions.php b/app/Services/Care/CarePermissions.php index 51dad15..8609acd 100644 --- a/app/Services/Care/CarePermissions.php +++ b/app/Services/Care/CarePermissions.php @@ -51,6 +51,7 @@ class CarePermissions 'admin.practitioners.view', 'admin.practitioners.manage', 'admin.members.view', 'admin.members.manage', 'settings.view', 'settings.manage', + 'devices.view', 'devices.manage', 'audit.view', 'audit.export', ]; diff --git a/app/Services/Care/ConsultationService.php b/app/Services/Care/ConsultationService.php index 4ca6b69..6d217aa 100644 --- a/app/Services/Care/ConsultationService.php +++ b/app/Services/Care/ConsultationService.php @@ -97,14 +97,25 @@ class ConsultationService /** * @param array $vitals + * @param array{source?: string, device_id?: int|null, raw_payload?: array|null} $provenance */ - protected function saveVitals(Consultation $consultation, string $ownerRef, array $vitals, ?string $actorRef): void - { - if (empty(array_filter($vitals))) { - return; + public function saveVitals( + Consultation $consultation, + string $ownerRef, + array $vitals, + ?string $actorRef = null, + array $provenance = [], + ): ?VitalSign { + $numeric = collect($vitals)->only([ + 'bp_systolic', 'bp_diastolic', 'pulse', 'temperature', + 'weight_kg', 'height_cm', 'spo2', 'respiratory_rate', + ])->filter(fn ($v) => $v !== null && $v !== '')->all(); + + if ($numeric === []) { + return null; } - VitalSign::create([ + return VitalSign::create([ 'owner_ref' => $ownerRef, 'consultation_id' => $consultation->id, 'bp_systolic' => $vitals['bp_systolic'] ?? null, @@ -117,6 +128,9 @@ class ConsultationService 'respiratory_rate' => $vitals['respiratory_rate'] ?? null, 'recorded_by' => $actorRef, 'recorded_at' => now(), + 'source' => $provenance['source'] ?? VitalSign::SOURCE_MANUAL, + 'device_id' => $provenance['device_id'] ?? null, + 'raw_payload' => $provenance['raw_payload'] ?? null, ]); } diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 6bd7890..645e11f 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -13,6 +13,7 @@ use App\Models\BillLineItem; use App\Models\Branch; use App\Models\Consultation; use App\Models\Department; +use App\Models\Device; use App\Models\Drug; use App\Models\DrugBatch; use App\Models\InvestigationRequest; @@ -122,6 +123,7 @@ class DemoTenantSeeder $ownerRef, $volumes, ); + $this->seedDevices($organization, $branches, $ownerRef); } if ($volumes['assessments'] > 0) { @@ -196,6 +198,8 @@ class DemoTenantSeeder DB::table('care_vital_signs')->where('owner_ref', $ownerRef)->delete(); DB::table('care_consultations')->where('owner_ref', $ownerRef)->delete(); + DB::table('care_devices')->where('owner_ref', $ownerRef)->delete(); + DB::table('care_appointments')->where('owner_ref', $ownerRef)->delete(); DB::table('care_visits')->where('owner_ref', $ownerRef)->delete(); @@ -678,6 +682,57 @@ class DemoTenantSeeder * @param list $patients * @param array $volumes */ + /** + * Demo hardware inventory: browser wedge scanner + agent thermometer per branch (Pro+). + * + * @param list $branches + */ + private function seedDevices(Organization $organization, array $branches, string $ownerRef): void + { + $deviceService = app(DeviceService::class); + + foreach ($branches as $i => $branch) { + Device::withTrashed()->updateOrCreate( + [ + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'name' => 'Demo barcode scanner', + ], + [ + 'owner_ref' => $ownerRef, + 'type' => 'barcode_scanner', + 'connection_mode' => Device::MODE_BROWSER_WEDGE, + 'status' => Device::STATUS_OFFLINE, + 'device_token_hash' => null, + 'metadata' => ['demo' => true], + 'deleted_at' => null, + ], + ); + + $thermo = Device::withTrashed()->updateOrCreate( + [ + 'organization_id' => $organization->id, + 'branch_id' => $branch->id, + 'name' => 'Demo thermometer', + ], + [ + 'owner_ref' => $ownerRef, + 'type' => 'thermometer', + 'connection_mode' => Device::MODE_AGENT, + 'status' => Device::STATUS_OFFLINE, + 'metadata' => ['demo' => true, 'note' => 'Token issued on first seed only if missing'], + 'deleted_at' => null, + ], + ); + + if (! $thermo->device_token_hash) { + // Deterministic demo token so sales can reuse docs examples after reseed. + $plain = 'demo-care-device-'.$organization->id.'-'.$branch->id.'-thermo'; + $thermo->update(['device_token_hash' => $deviceService->hashToken($plain)]); + } + } + } + private function seedPaidModules( Organization $organization, array $branches, diff --git a/app/Services/Care/DeviceService.php b/app/Services/Care/DeviceService.php new file mode 100644 index 0000000..d12eed8 --- /dev/null +++ b/app/Services/Care/DeviceService.php @@ -0,0 +1,99 @@ +where('device_token_hash', $this->hashToken($token)) + ->where('status', '!=', Device::STATUS_DISABLED) + ->first(); + } + + public function issueToken(Device $device): string + { + $plain = $this->generateToken(); + $device->update(['device_token_hash' => $this->hashToken($plain)]); + + return $plain; + } + + public function revokeToken(Device $device): Device + { + $device->update(['device_token_hash' => null]); + + return $device->fresh(); + } + + public function recordHeartbeat(Device $device, ?array $metadata = null): Device + { + $payload = [ + 'status' => Device::STATUS_ACTIVE, + 'last_seen_at' => now(), + ]; + + if ($metadata !== null) { + $payload['metadata'] = array_merge($device->metadata ?? [], [ + 'agent' => $metadata, + ]); + } + + $device->update($payload); + + return $device->fresh(); + } + + public function defaultConnectionMode(string $type): string + { + return match ($type) { + 'barcode_scanner', 'qr_scanner' => Device::MODE_BROWSER_WEDGE, + 'badge_printer' => Device::MODE_MANUAL, + 'thermometer', 'bp_monitor', 'pulse_ox', 'weight_scale', 'lab_analyzer', 'agent' => Device::MODE_AGENT, + default => Device::MODE_MANUAL, + }; + } + + public function typeRequiresPro(string $type): bool + { + return in_array($type, config('care.device_agent_types', []), true); + } + + public function canRegisterType(Organization $organization, string $type, PlanService $plans): bool + { + if (! $this->typeRequiresPro($type)) { + return true; + } + + return $plans->hasPaidPlan($organization); + } + + public function connectionHint(string $type, string $mode): string + { + return match ($mode) { + Device::MODE_BROWSER_WEDGE => 'Works in the browser today as a USB keyboard wedge (scan into the focused field). No local agent required.', + Device::MODE_AGENT => 'Needs the Care Device Agent on a local machine (serial / BLE / vendor SDK). Browser alone cannot talk to this hardware.', + Device::MODE_WEB_BLUETOOTH => 'Experimental Web Bluetooth path — not broadly supported; prefer the local agent for clinical devices.', + default => 'Manual / inventory only — no automated capture path configured.', + }; + } +} diff --git a/app/Services/Care/PatientService.php b/app/Services/Care/PatientService.php index 6cc6db8..2a52922 100644 --- a/app/Services/Care/PatientService.php +++ b/app/Services/Care/PatientService.php @@ -72,6 +72,40 @@ class PatientService return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString(); } + /** + * Exact patient_number match first, then fall back to broad search (first hit). + */ + public function findByScanCode(string $ownerRef, int $organizationId, string $code, ?int $branchId = null): ?Patient + { + $code = trim($code); + if ($code === '') { + return null; + } + + $base = $this->queryForOrganization($ownerRef, $organizationId) + ->when($branchId !== null, fn ($q) => $q->where('branch_id', $branchId)); + + $exact = (clone $base)->where('patient_number', $code)->first(); + if ($exact) { + return $exact; + } + + // Case-insensitive exact patient number. + $ci = (clone $base)->whereRaw('lower(patient_number) = ?', [strtolower($code)])->first(); + if ($ci) { + return $ci; + } + + return (clone $base) + ->where(function (Builder $inner) use ($code) { + $inner->where('patient_number', 'like', "%{$code}%") + ->orWhere('national_id', $code) + ->orWhere('phone', 'like', "%{$code}%"); + }) + ->orderByDesc('created_at') + ->first(); + } + /** * @param array $data */ diff --git a/bootstrap/app.php b/bootstrap/app.php index 50729d6..91dfd81 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -33,6 +33,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'care.setup' => \App\Http\Middleware\EnsureOrganizationSetup::class, 'care.ability' => \App\Http\Middleware\EnsureCareAbility::class, 'care.paid' => \App\Http\Middleware\EnsurePaidPlan::class, + 'care.device' => \App\Http\Middleware\AuthenticateCareDevice::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/care.php b/config/care.php index f5dbe2f..596e6b6 100644 --- a/config/care.php +++ b/config/care.php @@ -90,6 +90,11 @@ return [ 'assessment.cancelled' => 'Assessment cancelled', 'pathway.activated' => 'Clinical pathway activated', 'pathway.deactivated' => 'Clinical pathway deactivated', + 'device.created' => 'Device registered', + 'device.updated' => 'Device updated', + 'device.deleted' => 'Device removed', + 'device.token_issued' => 'Device token issued', + 'device.token_revoked' => 'Device token revoked', ], 'assessment_statuses' => [ @@ -282,6 +287,7 @@ return [ 'pharmacy', 'billing', 'queue_integration', + 'clinical_devices', ], ], 'enterprise' => [ @@ -296,12 +302,55 @@ return [ 'pharmacy', 'billing', 'queue_integration', + 'clinical_devices', // KD-18: org-level multi-patient assessment/outcome analytics 'assessment_analytics', ], ], ], + 'device_types' => [ + 'barcode_scanner' => 'Barcode scanner', + 'qr_scanner' => 'QR scanner', + 'badge_printer' => 'Badge / label printer', + 'thermometer' => 'Thermometer', + 'bp_monitor' => 'Blood pressure monitor', + 'pulse_ox' => 'Pulse oximeter', + 'weight_scale' => 'Weight scale', + 'lab_analyzer' => 'Lab analyzer', + 'agent' => 'Generic device agent', + 'other' => 'Other', + ], + + /** Types that need a local Care Device Agent (Pro+). Wedge scanners stay free. */ + 'device_agent_types' => [ + 'thermometer', + 'bp_monitor', + 'pulse_ox', + 'weight_scale', + 'lab_analyzer', + 'agent', + ], + + 'device_connection_modes' => [ + 'browser_wedge' => 'Browser keyboard wedge', + 'agent' => 'Local device agent', + 'web_bluetooth' => 'Web Bluetooth (experimental)', + 'manual' => 'Manual / inventory only', + ], + + 'device_statuses' => [ + 'active' => 'Active', + 'offline' => 'Offline', + 'disabled' => 'Disabled', + ], + + 'vital_sources' => [ + 'manual' => 'Manual entry', + 'device' => 'Device', + 'import' => 'Import', + ], + 'enterprise' => [ // Feature tier (not branch-gated); kept for config compatibility. 'min_branches' => (int) env('CARE_ENTERPRISE_MIN_BRANCHES', 1), diff --git a/database/migrations/2026_07_17_140000_create_care_devices_table.php b/database/migrations/2026_07_17_140000_create_care_devices_table.php new file mode 100644 index 0000000..2a61266 --- /dev/null +++ b/database/migrations/2026_07_17_140000_create_care_devices_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('branch_id')->nullable()->constrained('care_branches')->nullOnDelete(); + $table->string('name'); + $table->string('type'); // barcode_scanner, thermometer, … + $table->string('status')->default('offline'); // active, offline, disabled + $table->string('connection_mode')->default('manual'); // browser_wedge, agent, web_bluetooth, manual + $table->string('device_token_hash')->nullable()->unique(); + $table->timestamp('last_seen_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['organization_id', 'branch_id', 'status']); + $table->index(['owner_ref', 'type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('care_devices'); + } +}; diff --git a/database/migrations/2026_07_17_140100_add_device_provenance_to_care_vital_signs.php b/database/migrations/2026_07_17_140100_add_device_provenance_to_care_vital_signs.php new file mode 100644 index 0000000..6d2a2c7 --- /dev/null +++ b/database/migrations/2026_07_17_140100_add_device_provenance_to_care_vital_signs.php @@ -0,0 +1,26 @@ +string('source')->default('manual')->after('recorded_at'); // manual, device, import + $table->foreignId('device_id')->nullable()->after('source') + ->constrained('care_devices')->nullOnDelete(); + $table->json('raw_payload')->nullable()->after('device_id'); + }); + } + + public function down(): void + { + Schema::table('care_vital_signs', function (Blueprint $table) { + $table->dropConstrainedForeignId('device_id'); + $table->dropColumn(['source', 'raw_payload']); + }); + } +}; diff --git a/deployment/care-device-agent/.env.example b/deployment/care-device-agent/.env.example new file mode 100644 index 0000000..1520ba4 --- /dev/null +++ b/deployment/care-device-agent/.env.example @@ -0,0 +1,18 @@ +# Care API base (no trailing slash) +CARE_URL=https://care.ladill.com + +# Plaintext token from Settings → Devices (shown once on create/regenerate) +DEVICE_TOKEN= + +# Open consultation UUID to receive demo vitals +CONSULTATION_UUID= + +# Post a fake temperature reading when set (demo without serial hardware) +FAKE_READING=1 + +# Seconds between heartbeat / optional reading cycles +INTERVAL_SECONDS=30 + +# Optional agent identity +AGENT_HOSTNAME= +AGENT_VERSION=0.1.0 diff --git a/deployment/care-device-agent/README.md b/deployment/care-device-agent/README.md new file mode 100644 index 0000000..8ab205f --- /dev/null +++ b/deployment/care-device-agent/README.md @@ -0,0 +1,13 @@ +# Care Device Agent (demo) + +Minimal Node agent that authenticates with `X-Care-Device-Token` and posts heartbeats / stub vitals. + +```bash +cp .env.example .env +# edit CARE_URL, DEVICE_TOKEN, CONSULTATION_UUID +FAKE_READING=1 npm start +# or one shot: +FAKE_READING=1 npm run once +``` + +See `docs/devices.md` in the Care repo for API details. diff --git a/deployment/care-device-agent/index.js b/deployment/care-device-agent/index.js new file mode 100644 index 0000000..b6047b9 --- /dev/null +++ b/deployment/care-device-agent/index.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * Minimal Care Device Agent. + * + * Demo mode: FAKE_READING=1 posts stub vitals (no serial port). + * Production: replace readSerialStub() with a vendor/serial/BLE reader. + */ + +const fs = require('fs'); +const path = require('path'); + +function loadEnvFile() { + const envPath = path.join(__dirname, '.env'); + if (!fs.existsSync(envPath)) return; + for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + let val = trimmed.slice(eq + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + if (process.env[key] === undefined) process.env[key] = val; + } +} + +loadEnvFile(); + +const CARE_URL = (process.env.CARE_URL || '').replace(/\/$/, ''); +const DEVICE_TOKEN = process.env.DEVICE_TOKEN || ''; +const CONSULTATION_UUID = process.env.CONSULTATION_UUID || ''; +const FAKE_READING = ['1', 'true', 'yes'].includes(String(process.env.FAKE_READING || '').toLowerCase()); +const INTERVAL_SECONDS = Math.max(5, Number(process.env.INTERVAL_SECONDS || 30)); +const AGENT_VERSION = process.env.AGENT_VERSION || '0.1.0'; +const AGENT_HOSTNAME = process.env.AGENT_HOSTNAME || require('os').hostname(); +const ONCE = process.argv.includes('--once'); + +if (!CARE_URL || !DEVICE_TOKEN) { + console.error('Set CARE_URL and DEVICE_TOKEN (see .env.example).'); + process.exit(1); +} + +async function api(pathname, body) { + const res = await fetch(`${CARE_URL}/api/v1/device-agent${pathname}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Care-Device-Token': DEVICE_TOKEN, + }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let data; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { raw: text }; + } + if (!res.ok) { + const err = new Error(data.message || data.error || `HTTP ${res.status}`); + err.status = res.status; + err.data = data; + throw err; + } + return data; +} + +/** Stub serial read — replace with real driver for production. */ +function readSerialStub() { + if (!FAKE_READING) return null; + const base = 36.5 + Math.random() * 0.8; + return { + vitals: { + temperature: Math.round(base * 10) / 10, + pulse: 60 + Math.floor(Math.random() * 20), + spo2: 96 + Math.floor(Math.random() * 4), + }, + raw: { + driver: 'fake', + captured_at: new Date().toISOString(), + }, + }; +} + +async function tick() { + const hb = await api('/heartbeat', { + agent_version: AGENT_VERSION, + hostname: AGENT_HOSTNAME, + }); + console.log(`[heartbeat] status=${hb.status} last_seen=${hb.last_seen_at}`); + + const reading = readSerialStub(); + if (reading && CONSULTATION_UUID) { + const result = await api('/vitals', { + consultation_uuid: CONSULTATION_UUID, + vitals: reading.vitals, + raw: reading.raw, + }); + console.log(`[vitals] id=${result.vital_sign_id} temp=${reading.vitals.temperature}`); + } else if (reading && !CONSULTATION_UUID) { + console.log('[vitals] skipped — set CONSULTATION_UUID to post fake readings'); + } +} + +async function main() { + console.log(`Care Device Agent → ${CARE_URL} (fake=${FAKE_READING ? 'on' : 'off'})`); + await tick(); + if (ONCE) return; + setInterval(() => { + tick().catch((err) => console.error('[error]', err.message, err.data || '')); + }, INTERVAL_SECONDS * 1000); +} + +main().catch((err) => { + console.error(err.message, err.data || ''); + process.exit(1); +}); diff --git a/deployment/care-device-agent/package.json b/deployment/care-device-agent/package.json new file mode 100644 index 0000000..1c3a3e6 --- /dev/null +++ b/deployment/care-device-agent/package.json @@ -0,0 +1,14 @@ +{ + "name": "care-device-agent", + "version": "0.1.0", + "private": true, + "description": "Minimal Ladill Care Device Agent — heartbeat + stub vitals for demos", + "main": "index.js", + "scripts": { + "start": "node index.js", + "once": "node index.js --once" + }, + "engines": { + "node": ">=18" + } +} diff --git a/docs/devices.md b/docs/devices.md new file mode 100644 index 0000000..531a8d9 --- /dev/null +++ b/docs/devices.md @@ -0,0 +1,91 @@ +# Care clinical devices + +Ladill Care supports two realistic hardware paths: + +| Device | Connection | Plan | +|--------|------------|------| +| USB barcode / QR scanner | **Browser keyboard wedge** — scan into Patients or Lab | All plans | +| Badge / label printer | Browser print CSS (patient ID label) | All plans | +| Thermometer, BP monitor, pulse ox, scale, lab analyzer | **Care Device Agent** (local machine) | Pro / Enterprise | + +Browser pages cannot open serial ports or most BLE clinical devices without a local helper. The agent holds the vendor/serial connection and posts readings to Care with a device token. + +## Device registry + +**Settings → Devices** (hospital admin): + +1. Register a device (branch-scoped). +2. For agent types, Care shows a **plaintext token once** (create / regenerate). The DB stores only `device_token_hash`. +3. Status updates from agent heartbeats (`active` + `last_seen_at`). + +## Browser wedge (patients) + +1. Open **Patients**. +2. Focus the scan field (or scan while not typing in another input). +3. Scan a patient ID label (`patient_number`) → Care opens the patient record. +4. From the patient page, **Print ID label** for browser print. + +Lab **Collect sample** accepts a scanned `sample_barcode` before submit. + +## Agent API + +Base URL: `{APP_URL}/api/v1/device-agent` + +Auth (any one): + +- Header `X-Care-Device-Token: ` +- Header `Authorization: Bearer <plaintext token>` +- Header `X-Device-Token: <plaintext token>` (compat) + +Rate limit: 60 requests / minute / token. + +### Heartbeat + +```bash +curl -sS -X POST "$CARE_URL/api/v1/device-agent/heartbeat" \ + -H "X-Care-Device-Token: $DEVICE_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d '{"agent_version":"0.1.0","hostname":"nurse-station-1"}' +``` + +### Post vitals + +Provide `consultation_uuid` **or** `visit_uuid` (latest consultation on that visit). Readings create a `care_vital_signs` row with `source=device` and `device_id`. + +```bash +curl -sS -X POST "$CARE_URL/api/v1/device-agent/vitals" \ + -H "X-Care-Device-Token: $DEVICE_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d '{ + "consultation_uuid":"'"$CONSULTATION_UUID"'", + "vitals":{"temperature":36.8,"pulse":72,"spo2":98}, + "raw":{"driver":"stub","unit":"C"} + }' +``` + +Typed consultation vitals remain the source of truth for clinicians; agent posts append provenance-aware rows. + +### Collect sample barcode + +```bash +curl -sS -X POST "$CARE_URL/api/v1/device-agent/samples/collect" \ + -H "X-Care-Device-Token: $DEVICE_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d '{"investigation_uuid":"'"$INVESTIGATION_UUID"'","sample_barcode":"SMP-001"}' +``` + +## Minimal agent (demo) + +See `deployment/care-device-agent/`. + +```bash +cd deployment/care-device-agent +cp .env.example .env # set CARE_URL + DEVICE_TOKEN +npm install # optional; Node 18+ has fetch built-in +FAKE_READING=1 npm start +``` + +With `FAKE_READING=1` the agent heartbeats and posts a stub temperature every interval (no serial hardware). Point `CONSULTATION_UUID` at an open consultation for a live demo. diff --git a/resources/views/care/consultations/show.blade.php b/resources/views/care/consultations/show.blade.php index d7fc78e..b8caa5b 100644 --- a/resources/views/care/consultations/show.blade.php +++ b/resources/views/care/consultations/show.blade.php @@ -417,6 +417,7 @@ <div><dt class="text-slate-500">Pulse</dt><dd class="font-medium">{{ $vitals->pulse ?? '—' }}</dd></div> <div><dt class="text-slate-500">Temp</dt><dd class="font-medium">{{ $vitals->temperature ?? '—' }}°C</dd></div> <div><dt class="text-slate-500">SpO₂</dt><dd class="font-medium">{{ $vitals->spo2 ? $vitals->spo2.'%' : '—' }}</dd></div> + <div><dt class="text-slate-500">Source</dt><dd class="font-medium">{{ config('care.vital_sources.'.$vitals->source, $vitals->source ?? 'manual') }}@if ($vitals->device_id) · device #{{ $vitals->device_id }}@endif</dd></div> </dl> @endforeach </section> diff --git a/resources/views/care/devices/create.blade.php b/resources/views/care/devices/create.blade.php new file mode 100644 index 0000000..c84e605 --- /dev/null +++ b/resources/views/care/devices/create.blade.php @@ -0,0 +1,82 @@ +<x-app-layout title="Add device"> + <div class="mx-auto max-w-lg space-y-4"> + <div> + <a href="{{ route('care.devices.index') }}" class="text-sm text-slate-500 hover:text-slate-800">← Devices</a> + <h1 class="mt-2 text-xl font-semibold text-slate-900">Register device</h1> + <p class="mt-1 text-sm text-slate-500">Inventory is branch-scoped. Agent devices get a token shown once after create.</p> + </div> + + <form method="POST" action="{{ route('care.devices.store') }}" class="space-y-4 rounded-2xl border border-slate-200 bg-white p-6"> + @csrf + <div> + <label class="block text-sm font-medium text-slate-700">Name</label> + <input type="text" name="name" value="{{ old('name') }}" required placeholder="Nurse station scanner" + class="mt-1 w-full rounded-lg border-slate-300 text-sm"> + </div> + <div> + <label class="block text-sm font-medium text-slate-700">Type</label> + <select name="type" id="device-type" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"> + @foreach ($deviceTypes as $value => $label) + @php + $needsPro = in_array($value, $agentTypes, true); + @endphp + <option value="{{ $value }}" + data-mode="{{ $defaultModes[$value] ?? 'manual' }}" + data-pro="{{ $needsPro ? '1' : '0' }}" + @selected(old('type', 'barcode_scanner') === $value) + @disabled($needsPro && ! $hasAgentDevices)> + {{ $label }}@if ($needsPro && ! $hasAgentDevices) (Pro)@endif + </option> + @endforeach + </select> + @if (! $hasAgentDevices) + <p class="mt-1 text-xs text-amber-700">Clinical agent devices need Care Pro. USB barcode/QR scanners work on Free.</p> + @endif + </div> + <div> + <label class="block text-sm font-medium text-slate-700">Connection mode</label> + <select name="connection_mode" id="connection-mode" class="mt-1 w-full rounded-lg border-slate-300 text-sm"> + @foreach ($connectionModes as $value => $label) + <option value="{{ $value }}" @selected(old('connection_mode', 'browser_wedge') === $value)>{{ $label }}</option> + @endforeach + </select> + <p class="mt-1 text-xs text-slate-500" id="connection-hint">USB scanners act as a keyboard in the browser. Serial/BLE needs the local agent.</p> + </div> + <div> + <label class="block text-sm font-medium text-slate-700">Branch</label> + <select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm"> + <option value="">All branches</option> + @foreach ($branches as $branch) + <option value="{{ $branch->id }}" @selected(old('branch_id') == $branch->id)>{{ $branch->name }}</option> + @endforeach + </select> + </div> + <button type="submit" class="btn-primary w-full">Register device</button> + </form> + </div> + <script> + (() => { + const typeEl = document.getElementById('device-type'); + const modeEl = document.getElementById('connection-mode'); + const hintEl = document.getElementById('connection-hint'); + const hints = { + browser_wedge: 'Works in the browser today as a USB keyboard wedge. No local agent required.', + agent: 'Needs the Care Device Agent on a local machine (serial / BLE / vendor SDK).', + web_bluetooth: 'Experimental Web Bluetooth — prefer the local agent for clinical devices.', + manual: 'Manual / inventory only — no automated capture path.', + }; + function sync() { + const opt = typeEl.selectedOptions[0]; + if (!opt) return; + const mode = opt.dataset.mode || 'manual'; + modeEl.value = mode; + hintEl.textContent = hints[mode] || hints.manual; + } + typeEl.addEventListener('change', sync); + modeEl.addEventListener('change', () => { + hintEl.textContent = hints[modeEl.value] || hints.manual; + }); + sync(); + })(); + </script> +</x-app-layout> diff --git a/resources/views/care/devices/edit.blade.php b/resources/views/care/devices/edit.blade.php new file mode 100644 index 0000000..4dd3bed --- /dev/null +++ b/resources/views/care/devices/edit.blade.php @@ -0,0 +1,89 @@ +<x-app-layout title="Edit device"> + <div class="mx-auto max-w-lg space-y-4"> + <div> + <a href="{{ route('care.devices.index') }}" class="text-sm text-slate-500 hover:text-slate-800">← Devices</a> + <h1 class="mt-2 text-xl font-semibold text-slate-900">{{ $device->name }}</h1> + <p class="mt-1 text-sm text-slate-500">{{ $connectionHint }}</p> + </div> + + @if ($plainToken) + <div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950"> + <p class="font-medium">Device token (copy now — shown once)</p> + <code class="mt-2 block break-all rounded-lg bg-white px-3 py-2 font-mono text-xs text-slate-800">{{ $plainToken }}</code> + <p class="mt-2 text-xs text-amber-800">Send as <code class="font-mono">X-Care-Device-Token</code> or <code class="font-mono">Authorization: Bearer …</code> from the Care Device Agent.</p> + </div> + @elseif ($device->hasToken()) + <div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700"> + <p>A device token is active (hash stored). Regenerate to rotate; the previous token stops working immediately.</p> + </div> + @endif + + <form method="POST" action="{{ route('care.devices.update', $device) }}" class="space-y-4 rounded-2xl border border-slate-200 bg-white p-6"> + @csrf @method('PUT') + <div> + <label class="block text-sm font-medium text-slate-700">Name</label> + <input type="text" name="name" value="{{ old('name', $device->name) }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"> + </div> + <div> + <label class="block text-sm font-medium text-slate-700">Type</label> + <select name="type" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"> + @foreach ($deviceTypes as $value => $label) + @php $needsPro = in_array($value, $agentTypes, true); @endphp + <option value="{{ $value }}" @selected(old('type', $device->type) === $value) + @disabled($needsPro && ! $hasAgentDevices && $device->type !== $value)> + {{ $label }} + </option> + @endforeach + </select> + </div> + <div> + <label class="block text-sm font-medium text-slate-700">Connection mode</label> + <select name="connection_mode" class="mt-1 w-full rounded-lg border-slate-300 text-sm"> + @foreach ($connectionModes as $value => $label) + <option value="{{ $value }}" @selected(old('connection_mode', $device->connection_mode) === $value)>{{ $label }}</option> + @endforeach + </select> + </div> + <div> + <label class="block text-sm font-medium text-slate-700">Status</label> + <select name="status" class="mt-1 w-full rounded-lg border-slate-300 text-sm"> + @foreach ($deviceStatuses as $value => $label) + <option value="{{ $value }}" @selected(old('status', $device->status) === $value)>{{ $label }}</option> + @endforeach + </select> + </div> + <div> + <label class="block text-sm font-medium text-slate-700">Branch</label> + <select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm"> + <option value="">All branches</option> + @foreach ($branches as $branch) + <option value="{{ $branch->id }}" @selected(old('branch_id', $device->branch_id) == $branch->id)>{{ $branch->name }}</option> + @endforeach + </select> + </div> + <div class="flex gap-2"> + <button type="submit" class="btn-primary">Save</button> + <a href="{{ route('care.devices.index') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-600">Cancel</a> + </div> + </form> + + <div class="flex flex-wrap gap-2"> + <form method="POST" action="{{ route('care.devices.token.regenerate', $device) }}"> + @csrf + <button type="submit" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-50"> + {{ $device->hasToken() ? 'Regenerate token' : 'Issue agent token' }} + </button> + </form> + @if ($device->hasToken()) + <form method="POST" action="{{ route('care.devices.token.revoke', $device) }}"> + @csrf @method('DELETE') + <button type="submit" class="rounded-lg border border-rose-200 bg-white px-4 py-2 text-sm text-rose-700 hover:bg-rose-50">Revoke token</button> + </form> + @endif + <form method="POST" action="{{ route('care.devices.destroy', $device) }}" onsubmit="return confirm('Remove this device?')"> + @csrf @method('DELETE') + <button type="submit" class="rounded-lg border border-rose-200 bg-white px-4 py-2 text-sm text-rose-700 hover:bg-rose-50">Delete device</button> + </form> + </div> + </div> +</x-app-layout> diff --git a/resources/views/care/devices/index.blade.php b/resources/views/care/devices/index.blade.php new file mode 100644 index 0000000..adf7038 --- /dev/null +++ b/resources/views/care/devices/index.blade.php @@ -0,0 +1,71 @@ +<x-app-layout title="Devices"> + <div class="space-y-6"> + <x-care.page-hero + badge="Scanners · Vitals · Lab agents" + title="Devices" + description="Branch inventory for barcode scanners (browser wedge) and agent-connected clinical devices. USB scanners work in the browser; serial/BLE hardware needs the Care Device Agent." + :stats="[ + ['value' => number_format($heroStats['total']), 'label' => 'Devices'], + ['value' => number_format($heroStats['online']), 'label' => 'Online'], + ['value' => number_format($heroStats['agent']), 'label' => 'Agent-linked'], + ]"> + @if ($canManage) + <x-slot name="actions"> + <a href="{{ route('care.devices.create') }}" class="btn-primary">Add device</a> + </x-slot> + @endif + </x-care.page-hero> + + <div class="rounded-2xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm text-sky-900"> + <p class="font-medium">How devices connect</p> + <ul class="mt-1 list-disc space-y-1 pl-5 text-sky-800/90"> + <li><strong>Barcode / QR scanners</strong> — USB keyboard wedge works in Patients and Lab today. Free on all plans.</li> + <li><strong>Thermometers, BP, SpO₂, scales, analyzers</strong> — need a local Care Device Agent (Pro / Enterprise). See docs/devices.md.</li> + </ul> + </div> + + <div class="overflow-hidden rounded-2xl border border-slate-200 bg-white"> + <table class="min-w-full text-sm"> + <thead class="bg-slate-50 text-left text-xs uppercase text-slate-500"> + <tr> + <th class="px-4 py-3">Name</th> + <th class="px-4 py-3">Type</th> + <th class="px-4 py-3">Connection</th> + <th class="px-4 py-3">Status</th> + <th class="px-4 py-3">Branch</th> + <th class="px-4 py-3">Last seen</th> + <th class="px-4 py-3"></th> + </tr> + </thead> + <tbody class="divide-y divide-slate-50"> + @forelse ($devices as $device) + <tr> + <td class="px-4 py-3 font-medium text-slate-900">{{ $device->name }}</td> + <td class="px-4 py-3 text-slate-600">{{ $deviceTypes[$device->type] ?? $device->type }}</td> + <td class="px-4 py-3 text-slate-600">{{ $connectionModes[$device->connection_mode] ?? $device->connection_mode }}</td> + <td class="px-4 py-3"> + <span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {{ $device->isOnline() ? 'bg-emerald-100 text-emerald-800' : 'bg-slate-100 text-slate-600' }}"> + {{ $device->isOnline() ? 'Online' : ucfirst($device->status) }} + </span> + </td> + <td class="px-4 py-3 text-slate-600">{{ $device->branch?->name ?? 'All branches' }}</td> + <td class="px-4 py-3 text-slate-500">{{ $device->last_seen_at?->diffForHumans() ?? 'Never' }}</td> + <td class="px-4 py-3 text-right"> + @if ($canManage) + <a href="{{ route('care.devices.edit', $device) }}" class="text-sky-600 hover:text-sky-700">Edit</a> + @endif + </td> + </tr> + @empty + <tr> + <td colspan="7" class="px-4 py-8 text-center text-slate-500">No devices registered yet.</td> + </tr> + @endforelse + </tbody> + </table> + @if ($devices->hasPages()) + <div class="border-t border-slate-100 px-5 py-3">{{ $devices->links() }}</div> + @endif + </div> + </div> +</x-app-layout> diff --git a/resources/views/care/lab/requests/show.blade.php b/resources/views/care/lab/requests/show.blade.php index 17160e3..47a5731 100644 --- a/resources/views/care/lab/requests/show.blade.php +++ b/resources/views/care/lab/requests/show.blade.php @@ -7,7 +7,17 @@ </div> <div class="flex flex-wrap gap-2"> @if ($canManage && $investigation->status === \App\Models\InvestigationRequest::STATUS_PENDING) - <form method="POST" action="{{ route('care.lab.requests.collect-sample', $investigation) }}">@csrf<button class="btn-primary">Collect sample</button></form> + <form method="POST" action="{{ route('care.lab.requests.collect-sample', $investigation) }}" class="flex flex-wrap items-end gap-2"> + @csrf + <div> + <label for="sample_barcode" class="block text-xs font-medium text-slate-600">Sample barcode</label> + <input type="text" id="sample_barcode" name="sample_barcode" autocomplete="off" + placeholder="Scan or type barcode…" + class="mt-1 w-48 rounded-lg border-slate-300 font-mono text-sm" + autofocus> + </div> + <button class="btn-primary">Collect sample</button> + </form> @endif @if ($canManage && $investigation->status === \App\Models\InvestigationRequest::STATUS_SAMPLE_COLLECTED) <form method="POST" action="{{ route('care.lab.requests.start', $investigation) }}">@csrf<button class="btn-primary">Start processing</button></form> diff --git a/resources/views/care/patients/index.blade.php b/resources/views/care/patients/index.blade.php index dcbc940..37577c0 100644 --- a/resources/views/care/patients/index.blade.php +++ b/resources/views/care/patients/index.blade.php @@ -45,6 +45,22 @@ @endif </form> + <div class="rounded-2xl border border-slate-200 bg-white p-4" + x-data="patientScan({ lookupUrl: @js(route('care.patients.scan')) })" + @keydown.window="onGlobalKeydown($event)"> + <label for="patient-barcode-scan" class="text-sm font-medium text-slate-700">Barcode / QR scan</label> + <p class="mt-0.5 text-xs text-slate-400">USB scanners work as a keyboard wedge. Scan a patient ID label or type the number and press Enter.</p> + <div class="mt-2 flex flex-wrap gap-2"> + <input type="text" id="patient-barcode-scan" x-ref="scanInput" x-model="scanCode" + @keydown.enter.prevent="submitScan()" + placeholder="Scan patient ID…" autocomplete="off" + class="min-w-[220px] flex-1 rounded-lg border-slate-300 font-mono text-sm"> + <button type="button" class="btn-primary" @click="submitScan()">Open patient</button> + </div> + <p x-show="scanError" x-text="scanError" class="mt-2 text-sm text-rose-600" x-cloak></p> + <p x-show="scanOk" x-text="scanOk" class="mt-2 text-sm text-emerald-600" x-cloak></p> + </div> + <div class="overflow-hidden rounded-2xl border border-slate-200 bg-white"> <table class="min-w-full text-sm"> <thead class="bg-slate-50 text-left text-xs uppercase text-slate-500"> @@ -85,4 +101,64 @@ <div>{{ $patients->links() }}</div> </div> + <script> + function patientScan(config) { + return { + lookupUrl: config.lookupUrl, + scanCode: '', + scanError: '', + scanOk: '', + scanBuffer: '', + scanTimer: null, + submitScan() { + const code = (this.scanCode || '').trim(); + this.scanError = ''; + this.scanOk = ''; + if (!code) return; + fetch(this.lookupUrl + '?' + new URLSearchParams({ code }), { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + credentials: 'same-origin', + }).then(async (res) => { + const data = await res.json().catch(() => ({})); + if (!res.ok) { + this.scanError = data.message || 'No patient found for that barcode.'; + return; + } + this.scanOk = 'Opening ' + (data.patient?.name || 'patient') + '…'; + window.location.href = data.patient.url; + }).catch(() => { + this.scanError = 'Lookup failed. Try again.'; + }); + }, + onGlobalKeydown(e) { + if (this.shouldIgnoreScan(e)) return; + if (e.key === 'Enter') { + const code = (this.scanBuffer || '').trim(); + this.scanBuffer = ''; + if (code.length >= 3) { + e.preventDefault(); + this.scanCode = code; + this.submitScan(); + } + return; + } + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + this.scanBuffer += e.key; + clearTimeout(this.scanTimer); + this.scanTimer = setTimeout(() => { this.scanBuffer = ''; }, 120); + } + }, + shouldIgnoreScan(e) { + const tag = (e.target?.tagName || '').toLowerCase(); + if (tag === 'textarea') return true; + if (tag === 'input' && e.target?.id !== 'patient-barcode-scan') return true; + if (e.target?.isContentEditable) return true; + return false; + }, + }; + } + </script> </x-app-layout> diff --git a/resources/views/care/patients/label.blade.php b/resources/views/care/patients/label.blade.php new file mode 100644 index 0000000..bce6409 --- /dev/null +++ b/resources/views/care/patients/label.blade.php @@ -0,0 +1,84 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Patient label · {{ $patient->patient_number }}</title> + <style> + * { box-sizing: border-box; } + body { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + color: #0f172a; + background: #f1f5f9; + } + .toolbar { + display: flex; + gap: 0.75rem; + padding: 1rem; + background: #fff; + border-bottom: 1px solid #e2e8f0; + } + .toolbar a, .toolbar button { + font-family: system-ui, sans-serif; + font-size: 0.875rem; + padding: 0.5rem 0.9rem; + border-radius: 0.5rem; + border: 1px solid #cbd5e1; + background: #fff; + cursor: pointer; + text-decoration: none; + color: #334155; + } + .toolbar button.primary { + background: #0ea5e9; + border-color: #0284c7; + color: #fff; + } + .sheet { + display: flex; + justify-content: center; + padding: 2rem 1rem; + } + .label { + width: 90mm; + min-height: 40mm; + background: #fff; + border: 1px solid #94a3b8; + padding: 4mm 5mm; + } + .org { font-size: 9pt; color: #64748b; margin: 0 0 2mm; } + .id { font-size: 16pt; font-weight: 700; letter-spacing: 0.02em; margin: 0; } + .name { font-size: 12pt; margin: 2mm 0 0; font-family: system-ui, sans-serif; } + .meta { font-size: 9pt; color: #475569; margin: 1.5mm 0 0; font-family: system-ui, sans-serif; } + @media print { + body { background: #fff; } + .toolbar { display: none !important; } + .sheet { padding: 0; } + .label { border: none; } + } + </style> +</head> +<body> + <div class="toolbar"> + <button type="button" class="primary" onclick="window.print()">Print label</button> + <a href="{{ route('care.patients.show', $patient) }}">Back to patient</a> + </div> + <div class="sheet"> + <div class="label"> + <p class="org">{{ $organization->name }}</p> + <p class="id">{{ $patient->patient_number }}</p> + <p class="name">{{ $patient->fullName() }}</p> + <p class="meta"> + {{ $genders[$patient->gender] ?? '' }} + @if ($patient->date_of_birth) + · {{ $patient->date_of_birth->format('d M Y') }} + @endif + @if ($patient->branch) + · {{ $patient->branch->name }} + @endif + </p> + </div> + </div> +</body> +</html> diff --git a/resources/views/care/patients/show.blade.php b/resources/views/care/patients/show.blade.php index a10cf42..c4c0ac7 100644 --- a/resources/views/care/patients/show.blade.php +++ b/resources/views/care/patients/show.blade.php @@ -14,6 +14,7 @@ </div> @if ($canManage) <div class="flex gap-2"> + <a href="{{ route('care.patients.label', $patient) }}" target="_blank" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Print ID label</a> <a href="{{ route('care.patients.edit', $patient) }}" class="btn-primary">Edit</a> <x-confirm-dialog :name="'archive-patient-'.$patient->id" @@ -28,6 +29,10 @@ </x-slot:trigger> </x-confirm-dialog> </div> + @else + <div class="flex gap-2"> + <a href="{{ route('care.patients.label', $patient) }}" target="_blank" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Print ID label</a> + </div> @endif </div> diff --git a/resources/views/care/settings/edit.blade.php b/resources/views/care/settings/edit.blade.php index 0773077..e66f25b 100644 --- a/resources/views/care/settings/edit.blade.php +++ b/resources/views/care/settings/edit.blade.php @@ -48,8 +48,8 @@ </div> </x-settings.card> - @if ($canViewBranches || $canViewTeam) - <x-settings.card title="Branches & team" description="Locations and staff access for this facility. Multi-branch management requires Care Pro."> + @if ($canViewBranches || $canViewTeam || $canViewDevices) + <x-settings.card title="Branches & team" description="Locations, staff access, and clinical hardware for this facility. Multi-branch management requires Care Pro."> <ul class="space-y-3"> @if ($canViewBranches) <li> @@ -84,6 +84,23 @@ </a> </li> @endif + @if ($canViewDevices) + <li> + <a href="{{ route('care.devices.index') }}" + class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700"> + <span> + Devices + @if (! $hasClinicalDevicesFeature) + <span class="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700">Agent · Pro</span> + @endif + <span class="mt-0.5 block text-xs text-slate-500"> + Barcode scanners (browser) · agent-connected vitals / lab hardware + </span> + </span> + <span class="text-slate-400">&rarr;</span> + </a> + </li> + @endif </ul> </x-settings.card> @endif diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index bf38592..98541ed 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -97,7 +97,8 @@ $settingsActive = (request()->routeIs('care.settings*') && ! request()->routeIs('care.pro.*')) || request()->routeIs('care.branches.*') || request()->routeIs('care.members.*') - || request()->routeIs('care.integrations*'); + || request()->routeIs('care.integrations*') + || request()->routeIs('care.devices.*'); @endphp <nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4"> diff --git a/routes/api.php b/routes/api.php index 5937c5c..07981dd 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Api\AppointmentController; use App\Http\Controllers\Api\AssessmentController; use App\Http\Controllers\Api\BillController; use App\Http\Controllers\Api\ConsultationController; +use App\Http\Controllers\Api\DeviceAgentController; use App\Http\Controllers\Api\DrugController; use App\Http\Controllers\Api\InvestigationController; use App\Http\Controllers\Api\PathwayController; @@ -17,6 +18,12 @@ Route::get('/health', fn () => response()->json(['status' => 'ok', 'app' => 'car Route::post('/service-events', ServiceEventController::class)->name('api.service-events'); +Route::middleware(['care.device', 'throttle:care-device'])->prefix('v1/device-agent')->group(function () { + Route::post('/heartbeat', [DeviceAgentController::class, 'heartbeat'])->name('api.device-agent.heartbeat'); + Route::post('/vitals', [DeviceAgentController::class, 'storeVitals'])->name('api.device-agent.vitals'); + Route::post('/samples/collect', [DeviceAgentController::class, 'collectSample'])->name('api.device-agent.samples.collect'); +}); + Route::middleware(['auth:sanctum', 'care.setup'])->prefix('v1')->group(function () { Route::get('/patients', [PatientController::class, 'index'])->name('api.patients.index'); Route::post('/patients', [PatientController::class, 'store'])->name('api.patients.store'); diff --git a/routes/web.php b/routes/web.php index 6749f57..c9922d6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,6 +12,7 @@ use App\Http\Controllers\Care\BranchController; use App\Http\Controllers\Care\ConsultationController; use App\Http\Controllers\Care\DashboardController; use App\Http\Controllers\Care\DepartmentController; +use App\Http\Controllers\Care\DeviceController; use App\Http\Controllers\Care\DrugController; use App\Http\Controllers\Care\InvestigationController; use App\Http\Controllers\Care\InvestigationTypeController; @@ -61,9 +62,11 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/dashboard', [DashboardController::class, 'index'])->name('care.dashboard'); Route::get('/patients', [PatientController::class, 'index'])->name('care.patients.index'); + Route::get('/patients/scan', [PatientController::class, 'scanLookup'])->name('care.patients.scan'); Route::get('/patients/create', [PatientController::class, 'create'])->name('care.patients.create'); Route::post('/patients', [PatientController::class, 'store'])->name('care.patients.store'); Route::get('/patients/{patient}', [PatientController::class, 'show'])->name('care.patients.show'); + Route::get('/patients/{patient}/label', [PatientController::class, 'label'])->name('care.patients.label'); Route::post('/patients/{patient}/email', [PatientMessageController::class, 'sendEmail'])->name('care.patients.email'); Route::post('/patients/{patient}/sms', [PatientMessageController::class, 'sendSms'])->name('care.patients.sms'); Route::get('/patients/{patient}/edit', [PatientController::class, 'edit'])->name('care.patients.edit'); @@ -192,6 +195,15 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/settings/branches/{branch}/edit', [BranchController::class, 'edit'])->name('care.branches.edit'); Route::put('/settings/branches/{branch}', [BranchController::class, 'update'])->name('care.branches.update'); + Route::get('/settings/devices', [DeviceController::class, 'index'])->name('care.devices.index'); + Route::get('/settings/devices/create', [DeviceController::class, 'create'])->name('care.devices.create'); + Route::post('/settings/devices', [DeviceController::class, 'store'])->name('care.devices.store'); + Route::get('/settings/devices/{device}/edit', [DeviceController::class, 'edit'])->name('care.devices.edit'); + Route::put('/settings/devices/{device}', [DeviceController::class, 'update'])->name('care.devices.update'); + Route::delete('/settings/devices/{device}', [DeviceController::class, 'destroy'])->name('care.devices.destroy'); + Route::post('/settings/devices/{device}/token', [DeviceController::class, 'regenerateToken'])->name('care.devices.token.regenerate'); + Route::delete('/settings/devices/{device}/token', [DeviceController::class, 'revokeToken'])->name('care.devices.token.revoke'); + Route::redirect('/branches', '/settings/branches'); Route::redirect('/branches/create', '/settings/branches/create'); diff --git a/tests/Feature/CareDeviceTest.php b/tests/Feature/CareDeviceTest.php new file mode 100644 index 0000000..e630478 --- /dev/null +++ b/tests/Feature/CareDeviceTest.php @@ -0,0 +1,300 @@ +<?php + +namespace Tests\Feature; + +use App\Http\Middleware\EnsurePlatformSession; +use App\Models\Branch; +use App\Models\Consultation; +use App\Models\Device; +use App\Models\InvestigationRequest; +use App\Models\InvestigationType; +use App\Models\Member; +use App\Models\Organization; +use App\Models\Patient; +use App\Models\User; +use App\Models\Visit; +use App\Models\VitalSign; +use App\Services\Care\DeviceService; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Str; +use Tests\TestCase; + +class CareDeviceTest extends TestCase +{ + use RefreshDatabase; + + protected User $user; + + protected Organization $organization; + + protected Branch $branch; + + protected function setUp(): void + { + parent::setUp(); + $this->withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::create([ + 'public_id' => 'device-admin-001', + 'name' => 'Device Admin', + 'email' => 'devices@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Device Clinic', + 'slug' => 'device-clinic', + 'timezone' => 'UTC', + 'settings' => [ + 'onboarded' => true, + 'plan' => 'pro', + 'plan_expires_at' => now()->addYear()->toIso8601String(), + ], + ]); + + Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->user->public_id, + 'role' => 'hospital_admin', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + } + + public function test_admin_can_register_browser_wedge_scanner(): void + { + $this->actingAs($this->user) + ->post(route('care.devices.store'), [ + 'name' => 'Front desk scanner', + 'type' => 'barcode_scanner', + 'connection_mode' => 'browser_wedge', + 'branch_id' => $this->branch->id, + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('care_devices', [ + 'name' => 'Front desk scanner', + 'type' => 'barcode_scanner', + 'connection_mode' => 'browser_wedge', + 'organization_id' => $this->organization->id, + ]); + } + + public function test_free_plan_cannot_register_agent_thermometer(): void + { + $this->organization->update([ + 'settings' => ['onboarded' => true, 'plan' => 'free'], + ]); + + $this->actingAs($this->user) + ->from(route('care.devices.create')) + ->post(route('care.devices.store'), [ + 'name' => 'Ward thermometer', + 'type' => 'thermometer', + 'connection_mode' => 'agent', + 'branch_id' => $this->branch->id, + ]) + ->assertRedirect(route('care.devices.create')) + ->assertSessionHas('error'); + + $this->assertDatabaseMissing('care_devices', ['name' => 'Ward thermometer']); + } + + public function test_device_token_heartbeat_and_vitals_provenance(): void + { + $devices = app(DeviceService::class); + $device = Device::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'name' => 'Agent thermometer', + 'type' => 'thermometer', + 'connection_mode' => Device::MODE_AGENT, + 'status' => Device::STATUS_OFFLINE, + ]); + $token = $devices->issueToken($device); + + $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-DEV-0001', + 'first_name' => 'Ama', + 'last_name' => 'Mensah', + ]); + + $visit = Visit::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'status' => Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + ]); + + $consultation = Consultation::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'visit_id' => $visit->id, + 'patient_id' => $patient->id, + 'status' => Consultation::STATUS_DRAFT, + 'started_at' => now(), + ]); + + $this->postJson(route('api.device-agent.heartbeat'), [ + 'agent_version' => '0.1.0', + 'hostname' => 'test-host', + ], [ + 'X-Care-Device-Token' => $token, + ]) + ->assertOk() + ->assertJsonPath('status', 'active'); + + $device->refresh(); + $this->assertSame(Device::STATUS_ACTIVE, $device->status); + $this->assertNotNull($device->last_seen_at); + + $this->postJson(route('api.device-agent.vitals'), [ + 'consultation_uuid' => $consultation->uuid, + 'vitals' => [ + 'temperature' => 37.1, + 'pulse' => 78, + ], + 'raw' => ['driver' => 'test'], + ], [ + 'X-Care-Device-Token' => $token, + ]) + ->assertCreated() + ->assertJsonPath('source', 'device'); + + $vital = VitalSign::first(); + $this->assertNotNull($vital); + $this->assertSame(VitalSign::SOURCE_DEVICE, $vital->source); + $this->assertSame($device->id, $vital->device_id); + $this->assertSame('37.1', (string) $vital->temperature); + $this->assertSame('test', $vital->raw_payload['driver'] ?? null); + } + + public function test_invalid_device_token_is_rejected(): void + { + $this->postJson(route('api.device-agent.heartbeat'), [], [ + 'X-Care-Device-Token' => 'not-a-real-token', + ])->assertUnauthorized(); + } + + public function test_patient_scan_lookup_exact_patient_number(): void + { + $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-SCAN-9999', + 'first_name' => 'Kofi', + 'last_name' => 'Asante', + ]); + + $this->actingAs($this->user) + ->getJson(route('care.patients.scan', ['code' => 'LC-SCAN-9999'])) + ->assertOk() + ->assertJsonPath('patient.patient_number', 'LC-SCAN-9999') + ->assertJsonPath('patient.uuid', $patient->uuid); + + $this->actingAs($this->user) + ->getJson(route('care.patients.scan', ['code' => 'UNKNOWN-CODE'])) + ->assertNotFound(); + } + + public function test_agent_can_collect_sample_barcode(): void + { + $devices = app(DeviceService::class); + $device = Device::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'name' => 'Lab scanner agent', + 'type' => 'barcode_scanner', + 'connection_mode' => Device::MODE_AGENT, + 'status' => Device::STATUS_OFFLINE, + ]); + $token = $devices->issueToken($device); + + $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-LAB-0001', + 'first_name' => 'Efua', + 'last_name' => 'Boateng', + ]); + + $visit = Visit::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'status' => Visit::STATUS_OPEN, + 'checked_in_at' => now(), + ]); + + $type = InvestigationType::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'FBC', + 'code' => 'FBC', + 'category' => 'blood', + 'is_active' => true, + ]); + + $investigation = InvestigationRequest::create([ + 'uuid' => (string) Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'visit_id' => $visit->id, + 'patient_id' => $patient->id, + 'investigation_type_id' => $type->id, + 'status' => InvestigationRequest::STATUS_PENDING, + 'priority' => 'routine', + ]); + + $this->postJson(route('api.device-agent.samples.collect'), [ + 'investigation_uuid' => $investigation->uuid, + 'sample_barcode' => 'SMP-AGENT-1', + ], [ + 'Authorization' => 'Bearer '.$token, + ]) + ->assertOk() + ->assertJsonPath('sample_barcode', 'SMP-AGENT-1') + ->assertJsonPath('status', InvestigationRequest::STATUS_SAMPLE_COLLECTED); + } + + public function test_patient_label_prints(): void + { + $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-LBL-0001', + 'first_name' => 'Yaw', + 'last_name' => 'Owusu', + ]); + + $this->actingAs($this->user) + ->get(route('care.patients.label', $patient)) + ->assertOk() + ->assertSee('LC-LBL-0001') + ->assertSee('Yaw Owusu'); + } +}