Add clinical device registry, browser wedge scan, and local agent API.
Deploy Ladill Care / deploy (push) Successful in 1m39s

Branch-scoped Care devices with hashed agent tokens, patient barcode lookup/labels, and provenance-aware vitals from a minimal device agent (Pro for agent hardware; wedge free).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-17 14:28:46 +00:00
co-authored by Cursor
parent 1da90203e8
commit e0a7a64d38
37 changed files with 1970 additions and 10 deletions
+3
View File
@@ -71,6 +71,9 @@ SERVICE_EVENTS_INBOUND_SECRET=
DEMO_ACCOUNTS_ENABLED=false DEMO_ACCOUNTS_ENABLED=false
DEMO_RESET_ON_LOGIN=true 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) --- # --- Inbound service API keys (sibling Ladill apps calling Care) ---
CARE_API_KEY_FRONTDESK= CARE_API_KEY_FRONTDESK=
CARE_API_KEY_CRM= CARE_API_KEY_CRM=
+1
View File
@@ -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) - **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) - **Reports** — patients, appointments, lab, finance, clinical (CSV export)
- **Admin** — branches, departments, practitioners, members, audit log - **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 ## Platform integration
@@ -0,0 +1,165 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Consultation;
use App\Models\Device;
use App\Models\InvestigationRequest;
use App\Models\Visit;
use App\Models\VitalSign;
use App\Services\Care\ConsultationService;
use App\Services\Care\DeviceService;
use App\Services\Care\InvestigationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DeviceAgentController extends Controller
{
public function __construct(
protected DeviceService $devices,
protected ConsultationService $consultations,
protected InvestigationService $investigations,
) {}
public function heartbeat(Request $request): JsonResponse
{
/** @var Device $device */
$device = $request->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<string, mixed> $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();
}
}
@@ -0,0 +1,255 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Device;
use App\Services\Care\DeviceService;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PlanService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DeviceController extends Controller
{
use ScopesToAccount;
public function __construct(
protected DeviceService $devices,
protected PlanService $plans,
) {}
public function index(Request $request): View
{
$this->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<string, mixed>
*/
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<int, Branch> */
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();
}
}
@@ -10,6 +10,7 @@ use App\Services\Care\CareFeatures;
use App\Services\Care\CarePermissions; use App\Services\Care\CarePermissions;
use App\Services\Care\OrganizationResolver; use App\Services\Care\OrganizationResolver;
use App\Services\Care\PatientService; use App\Services\Care\PatientService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
@@ -49,6 +50,58 @@ class PatientController extends Controller
return view('care.patients.index', compact('patients', 'organization', 'heroStats')); 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 public function create(Request $request): View
{ {
$this->authorizeAbility($request, 'patients.manage'); $this->authorizeAbility($request, 'patients.manage');
@@ -54,7 +54,9 @@ class SettingsController extends Controller
'branchCount' => $branchCount, 'branchCount' => $branchCount,
'canViewBranches' => $permissions->can($member, 'admin.branches.view'), 'canViewBranches' => $permissions->can($member, 'admin.branches.view'),
'canViewTeam' => $permissions->can($member, 'admin.members.view'), 'canViewTeam' => $permissions->can($member, 'admin.members.view'),
'canViewDevices' => $permissions->can($member, 'devices.view'),
'hasBranchesFeature' => $plans->hasFeature($organization, 'branches'), 'hasBranchesFeature' => $plans->hasFeature($organization, 'branches'),
'hasClinicalDevicesFeature' => $plans->hasFeature($organization, 'clinical_devices'),
'canUseQueueIntegration' => $plans->canUseQueueIntegration($organization), 'canUseQueueIntegration' => $plans->canUseQueueIntegration($organization),
'assessmentsEngineEnabled' => $careFeatures->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE), 'assessmentsEngineEnabled' => $careFeatures->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE),
'messagingSmsReady' => $suiteSmsReady || $credential->hasValidSms(), 'messagingSmsReady' => $suiteSmsReady || $credential->hasValidSms(),
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Middleware;
use App\Services\Care\DeviceService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateCareDevice
{
public function __construct(
protected DeviceService $devices,
) {}
public function handle(Request $request, Closure $next): Response
{
$token = $this->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;
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Device extends Model
{
use BelongsToOwner, SoftDeletes;
public const STATUS_ACTIVE = 'active';
public const STATUS_OFFLINE = 'offline';
public const STATUS_DISABLED = 'disabled';
public const MODE_BROWSER_WEDGE = 'browser_wedge';
public const MODE_AGENT = 'agent';
public const MODE_WEB_BLUETOOTH = 'web_bluetooth';
public const MODE_MANUAL = 'manual';
protected $table = 'care_devices';
protected $fillable = [
'owner_ref', 'organization_id', 'branch_id', 'name', 'type',
'status', 'connection_mode', 'device_token_hash', 'last_seen_at', 'metadata',
];
protected $hidden = [
'device_token_hash',
];
protected function casts(): array
{
return [
'metadata' => '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);
}
}
+13 -1
View File
@@ -12,10 +12,16 @@ class VitalSign extends Model
protected $table = 'care_vital_signs'; protected $table = 'care_vital_signs';
public const SOURCE_MANUAL = 'manual';
public const SOURCE_DEVICE = 'device';
public const SOURCE_IMPORT = 'import';
protected $fillable = [ protected $fillable = [
'owner_ref', 'consultation_id', 'bp_systolic', 'bp_diastolic', 'pulse', 'owner_ref', 'consultation_id', 'bp_systolic', 'bp_diastolic', 'pulse',
'temperature', 'weight_kg', 'height_cm', 'spo2', 'respiratory_rate', '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 protected function casts(): array
@@ -25,6 +31,7 @@ class VitalSign extends Model
'weight_kg' => 'decimal:2', 'weight_kg' => 'decimal:2',
'height_cm' => 'decimal:1', 'height_cm' => 'decimal:1',
'recorded_at' => 'datetime', 'recorded_at' => 'datetime',
'raw_payload' => 'array',
]; ];
} }
@@ -32,4 +39,9 @@ class VitalSign extends Model
{ {
return $this->belongsTo(Consultation::class, 'consultation_id'); return $this->belongsTo(Consultation::class, 'consultation_id');
} }
public function device(): BelongsTo
{
return $this->belongsTo(Device::class, 'device_id');
}
} }
+12
View File
@@ -7,7 +7,10 @@ use App\Listeners\PlatformServiceEventListener;
use App\Models\User; use App\Models\User;
use App\Services\Care\OrganizationResolver; use App\Services\Care\OrganizationResolver;
use App\Services\Care\PlanService; use App\Services\Care\PlanService;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@@ -22,6 +25,15 @@ class AppServiceProvider extends ServiceProvider
{ {
Event::listen(ServiceEventOccurred::class, PlatformServiceEventListener::class); 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([ View::composer([
'partials.sidebar', 'partials.sidebar',
'partials.topbar-desktop-widgets', 'partials.topbar-desktop-widgets',
+1
View File
@@ -51,6 +51,7 @@ class CarePermissions
'admin.practitioners.view', 'admin.practitioners.manage', 'admin.practitioners.view', 'admin.practitioners.manage',
'admin.members.view', 'admin.members.manage', 'admin.members.view', 'admin.members.manage',
'settings.view', 'settings.manage', 'settings.view', 'settings.manage',
'devices.view', 'devices.manage',
'audit.view', 'audit.export', 'audit.view', 'audit.export',
]; ];
+19 -5
View File
@@ -97,14 +97,25 @@ class ConsultationService
/** /**
* @param array<string, mixed> $vitals * @param array<string, mixed> $vitals
* @param array{source?: string, device_id?: int|null, raw_payload?: array<string, mixed>|null} $provenance
*/ */
protected function saveVitals(Consultation $consultation, string $ownerRef, array $vitals, ?string $actorRef): void public function saveVitals(
{ Consultation $consultation,
if (empty(array_filter($vitals))) { string $ownerRef,
return; 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, 'owner_ref' => $ownerRef,
'consultation_id' => $consultation->id, 'consultation_id' => $consultation->id,
'bp_systolic' => $vitals['bp_systolic'] ?? null, 'bp_systolic' => $vitals['bp_systolic'] ?? null,
@@ -117,6 +128,9 @@ class ConsultationService
'respiratory_rate' => $vitals['respiratory_rate'] ?? null, 'respiratory_rate' => $vitals['respiratory_rate'] ?? null,
'recorded_by' => $actorRef, 'recorded_by' => $actorRef,
'recorded_at' => now(), 'recorded_at' => now(),
'source' => $provenance['source'] ?? VitalSign::SOURCE_MANUAL,
'device_id' => $provenance['device_id'] ?? null,
'raw_payload' => $provenance['raw_payload'] ?? null,
]); ]);
} }
+55
View File
@@ -13,6 +13,7 @@ use App\Models\BillLineItem;
use App\Models\Branch; use App\Models\Branch;
use App\Models\Consultation; use App\Models\Consultation;
use App\Models\Department; use App\Models\Department;
use App\Models\Device;
use App\Models\Drug; use App\Models\Drug;
use App\Models\DrugBatch; use App\Models\DrugBatch;
use App\Models\InvestigationRequest; use App\Models\InvestigationRequest;
@@ -122,6 +123,7 @@ class DemoTenantSeeder
$ownerRef, $ownerRef,
$volumes, $volumes,
); );
$this->seedDevices($organization, $branches, $ownerRef);
} }
if ($volumes['assessments'] > 0) { if ($volumes['assessments'] > 0) {
@@ -196,6 +198,8 @@ class DemoTenantSeeder
DB::table('care_vital_signs')->where('owner_ref', $ownerRef)->delete(); DB::table('care_vital_signs')->where('owner_ref', $ownerRef)->delete();
DB::table('care_consultations')->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_appointments')->where('owner_ref', $ownerRef)->delete();
DB::table('care_visits')->where('owner_ref', $ownerRef)->delete(); DB::table('care_visits')->where('owner_ref', $ownerRef)->delete();
@@ -678,6 +682,57 @@ class DemoTenantSeeder
* @param list<Patient> $patients * @param list<Patient> $patients
* @param array<string, mixed> $volumes * @param array<string, mixed> $volumes
*/ */
/**
* Demo hardware inventory: browser wedge scanner + agent thermometer per branch (Pro+).
*
* @param list<Branch> $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( private function seedPaidModules(
Organization $organization, Organization $organization,
array $branches, array $branches,
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Services\Care;
use App\Models\Device;
use App\Models\Organization;
use Illuminate\Support\Str;
class DeviceService
{
public function generateToken(): string
{
return Str::random(48);
}
public function hashToken(string $token): string
{
return hash('sha256', $token);
}
public function findByToken(string $token): ?Device
{
if ($token === '') {
return null;
}
return Device::query()
->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.',
};
}
}
+34
View File
@@ -72,6 +72,40 @@ class PatientService
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString(); 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<string, mixed> $data * @param array<string, mixed> $data
*/ */
+1
View File
@@ -33,6 +33,7 @@ return Application::configure(basePath: dirname(__DIR__))
'care.setup' => \App\Http\Middleware\EnsureOrganizationSetup::class, 'care.setup' => \App\Http\Middleware\EnsureOrganizationSetup::class,
'care.ability' => \App\Http\Middleware\EnsureCareAbility::class, 'care.ability' => \App\Http\Middleware\EnsureCareAbility::class,
'care.paid' => \App\Http\Middleware\EnsurePaidPlan::class, 'care.paid' => \App\Http\Middleware\EnsurePaidPlan::class,
'care.device' => \App\Http\Middleware\AuthenticateCareDevice::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
+49
View File
@@ -90,6 +90,11 @@ return [
'assessment.cancelled' => 'Assessment cancelled', 'assessment.cancelled' => 'Assessment cancelled',
'pathway.activated' => 'Clinical pathway activated', 'pathway.activated' => 'Clinical pathway activated',
'pathway.deactivated' => 'Clinical pathway deactivated', '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' => [ 'assessment_statuses' => [
@@ -282,6 +287,7 @@ return [
'pharmacy', 'pharmacy',
'billing', 'billing',
'queue_integration', 'queue_integration',
'clinical_devices',
], ],
], ],
'enterprise' => [ 'enterprise' => [
@@ -296,12 +302,55 @@ return [
'pharmacy', 'pharmacy',
'billing', 'billing',
'queue_integration', 'queue_integration',
'clinical_devices',
// KD-18: org-level multi-patient assessment/outcome analytics // KD-18: org-level multi-patient assessment/outcome analytics
'assessment_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' => [ 'enterprise' => [
// Feature tier (not branch-gated); kept for config compatibility. // Feature tier (not branch-gated); kept for config compatibility.
'min_branches' => (int) env('CARE_ENTERPRISE_MIN_BRANCHES', 1), 'min_branches' => (int) env('CARE_ENTERPRISE_MIN_BRANCHES', 1),
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('care_devices', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('care_vital_signs', function (Blueprint $table) {
$table->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']);
});
}
};
+18
View File
@@ -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
+13
View File
@@ -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.
+120
View File
@@ -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);
});
+14
View File
@@ -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"
}
}
+91
View File
@@ -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: <plaintext 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.
@@ -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">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">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">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> </dl>
@endforeach @endforeach
</section> </section>
@@ -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>
@@ -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>
@@ -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>
@@ -7,7 +7,17 @@
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@if ($canManage && $investigation->status === \App\Models\InvestigationRequest::STATUS_PENDING) @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 @endif
@if ($canManage && $investigation->status === \App\Models\InvestigationRequest::STATUS_SAMPLE_COLLECTED) @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> <form method="POST" action="{{ route('care.lab.requests.start', $investigation) }}">@csrf<button class="btn-primary">Start processing</button></form>
@@ -45,6 +45,22 @@
@endif @endif
</form> </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"> <div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full text-sm"> <table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500"> <thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
@@ -85,4 +101,64 @@
<div>{{ $patients->links() }}</div> <div>{{ $patients->links() }}</div>
</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> </x-app-layout>
@@ -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>
@@ -14,6 +14,7 @@
</div> </div>
@if ($canManage) @if ($canManage)
<div class="flex gap-2"> <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> <a href="{{ route('care.patients.edit', $patient) }}" class="btn-primary">Edit</a>
<x-confirm-dialog <x-confirm-dialog
:name="'archive-patient-'.$patient->id" :name="'archive-patient-'.$patient->id"
@@ -28,6 +29,10 @@
</x-slot:trigger> </x-slot:trigger>
</x-confirm-dialog> </x-confirm-dialog>
</div> </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 @endif
</div> </div>
+19 -2
View File
@@ -48,8 +48,8 @@
</div> </div>
</x-settings.card> </x-settings.card>
@if ($canViewBranches || $canViewTeam) @if ($canViewBranches || $canViewTeam || $canViewDevices)
<x-settings.card title="Branches & team" description="Locations and staff access for this facility. Multi-branch management requires Care Pro."> <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"> <ul class="space-y-3">
@if ($canViewBranches) @if ($canViewBranches)
<li> <li>
@@ -84,6 +84,23 @@
</a> </a>
</li> </li>
@endif @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> </ul>
</x-settings.card> </x-settings.card>
@endif @endif
+2 -1
View File
@@ -97,7 +97,8 @@
$settingsActive = (request()->routeIs('care.settings*') && ! request()->routeIs('care.pro.*')) $settingsActive = (request()->routeIs('care.settings*') && ! request()->routeIs('care.pro.*'))
|| request()->routeIs('care.branches.*') || request()->routeIs('care.branches.*')
|| request()->routeIs('care.members.*') || request()->routeIs('care.members.*')
|| request()->routeIs('care.integrations*'); || request()->routeIs('care.integrations*')
|| request()->routeIs('care.devices.*');
@endphp @endphp
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4"> <nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
+7
View File
@@ -4,6 +4,7 @@ use App\Http\Controllers\Api\AppointmentController;
use App\Http\Controllers\Api\AssessmentController; use App\Http\Controllers\Api\AssessmentController;
use App\Http\Controllers\Api\BillController; use App\Http\Controllers\Api\BillController;
use App\Http\Controllers\Api\ConsultationController; use App\Http\Controllers\Api\ConsultationController;
use App\Http\Controllers\Api\DeviceAgentController;
use App\Http\Controllers\Api\DrugController; use App\Http\Controllers\Api\DrugController;
use App\Http\Controllers\Api\InvestigationController; use App\Http\Controllers\Api\InvestigationController;
use App\Http\Controllers\Api\PathwayController; 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::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::middleware(['auth:sanctum', 'care.setup'])->prefix('v1')->group(function () {
Route::get('/patients', [PatientController::class, 'index'])->name('api.patients.index'); Route::get('/patients', [PatientController::class, 'index'])->name('api.patients.index');
Route::post('/patients', [PatientController::class, 'store'])->name('api.patients.store'); Route::post('/patients', [PatientController::class, 'store'])->name('api.patients.store');
+12
View File
@@ -12,6 +12,7 @@ use App\Http\Controllers\Care\BranchController;
use App\Http\Controllers\Care\ConsultationController; use App\Http\Controllers\Care\ConsultationController;
use App\Http\Controllers\Care\DashboardController; use App\Http\Controllers\Care\DashboardController;
use App\Http\Controllers\Care\DepartmentController; use App\Http\Controllers\Care\DepartmentController;
use App\Http\Controllers\Care\DeviceController;
use App\Http\Controllers\Care\DrugController; use App\Http\Controllers\Care\DrugController;
use App\Http\Controllers\Care\InvestigationController; use App\Http\Controllers\Care\InvestigationController;
use App\Http\Controllers\Care\InvestigationTypeController; 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('/dashboard', [DashboardController::class, 'index'])->name('care.dashboard');
Route::get('/patients', [PatientController::class, 'index'])->name('care.patients.index'); 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::get('/patients/create', [PatientController::class, 'create'])->name('care.patients.create');
Route::post('/patients', [PatientController::class, 'store'])->name('care.patients.store'); 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}', [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}/email', [PatientMessageController::class, 'sendEmail'])->name('care.patients.email');
Route::post('/patients/{patient}/sms', [PatientMessageController::class, 'sendSms'])->name('care.patients.sms'); Route::post('/patients/{patient}/sms', [PatientMessageController::class, 'sendSms'])->name('care.patients.sms');
Route::get('/patients/{patient}/edit', [PatientController::class, 'edit'])->name('care.patients.edit'); 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::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::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', '/settings/branches');
Route::redirect('/branches/create', '/settings/branches/create'); Route::redirect('/branches/create', '/settings/branches/create');
+300
View File
@@ -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');
}
}