Add clinical device registry, browser wedge scan, and local agent API.
Deploy Ladill Care / deploy (push) Successful in 1m39s
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:
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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\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');
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -97,14 +97,25 @@ class ConsultationService
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Patient> $patients
|
||||
* @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(
|
||||
Organization $organization,
|
||||
array $branches,
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<string, mixed> $data
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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">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>
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@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>
|
||||
|
||||
|
||||
@@ -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">→</span>
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</x-settings.card>
|
||||
@endif
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user