Initial Ladill Care release.
Deploy Ladill Care / deploy (push) Failing after 13s

Healthcare management app: patients, appointments, consultations, lab,
pharmacy inventory, billing, and reports at care.ladill.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-29 11:36:22 +00:00
co-authored by Cursor
commit 6c9c742ed8
300 changed files with 30741 additions and 0 deletions
@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Models\Appointment;
use App\Services\Care\AppointmentService;
use App\Services\Care\OrganizationResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AppointmentController extends Controller
{
use ScopesApiToAccount;
public function __construct(
protected AppointmentService $appointments,
) {}
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'appointments.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$appointments = $this->appointments->list(
$this->ownerRef($request),
$organization->id,
$request->only(['status', 'practitioner_id', 'date', 'patient_id', 'per_page']),
$branchScope,
);
return response()->json($appointments);
}
public function store(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$organization = $this->organization($request);
$appointment = $this->appointments->book(
$organization,
$this->ownerRef($request),
$this->validatedAppointmentData($request),
$this->ownerRef($request),
);
return response()->json($appointment, 201);
}
public function walkIn(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$organization = $this->organization($request);
$appointment = $this->appointments->walkIn(
$organization,
$this->ownerRef($request),
$this->validatedWalkInData($request),
$this->ownerRef($request),
);
return response()->json($appointment, 201);
}
public function show(Request $request, Appointment $appointment): JsonResponse
{
$this->authorizeAbility($request, 'appointments.view');
$this->authorizeAppointment($request, $appointment);
return response()->json($appointment->load(['patient', 'practitioner', 'branch', 'visit', 'consultation']));
}
public function checkIn(Request $request, Appointment $appointment): JsonResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeAppointment($request, $appointment);
$updated = $this->appointments->checkIn($appointment, $this->ownerRef($request), $this->ownerRef($request));
return response()->json($updated);
}
public function cancel(Request $request, Appointment $appointment): JsonResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeAppointment($request, $appointment);
$updated = $this->appointments->cancel($appointment, $this->ownerRef($request), $this->ownerRef($request));
return response()->json($updated);
}
protected function authorizeAppointment(Request $request, Appointment $appointment): void
{
$this->authorizeOwner($request, $appointment);
abort_unless($appointment->organization_id === $this->organization($request)->id, 404);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchScope !== null && $appointment->branch_id !== $branchScope) {
abort(404);
}
}
/**
* @return array<string, mixed>
*/
protected function validatedAppointmentData(Request $request): array
{
return $request->validate([
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
'patient_id' => ['required', 'integer', 'exists:care_patients,id'],
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
'department_id' => ['nullable', 'integer', 'exists:care_departments,id'],
'scheduled_at' => ['required', 'date', 'after:now'],
'reason' => ['nullable', 'string', 'max:1000'],
'notes' => ['nullable', 'string', 'max:5000'],
]);
}
/**
* @return array<string, mixed>
*/
protected function validatedWalkInData(Request $request): array
{
return $request->validate([
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
'patient_id' => ['required', 'integer', 'exists:care_patients,id'],
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
'department_id' => ['nullable', 'integer', 'exists:care_departments,id'],
'reason' => ['nullable', 'string', 'max:1000'],
'notes' => ['nullable', 'string', 'max:5000'],
]);
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Models\Bill;
use App\Models\Visit;
use App\Services\Care\BillService;
use App\Services\Care\OrganizationResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BillController extends Controller
{
use ScopesApiToAccount;
public function __construct(
protected BillService $bills,
) {}
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'bills.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$bills = $this->bills->list(
$this->ownerRef($request),
$organization->id,
$request->only(['status', 'patient_id', 'per_page']),
$branchScope,
);
return response()->json($bills);
}
public function generate(Request $request, Visit $visit): JsonResponse
{
$this->authorizeAbility($request, 'bills.manage');
$this->authorizeVisit($request, $visit);
$bill = $this->bills->generateFromVisit($visit, $this->ownerRef($request), $this->ownerRef($request));
return response()->json($bill, 201);
}
public function show(Request $request, Bill $bill): JsonResponse
{
$this->authorizeAbility($request, 'bills.view');
$this->authorizeBill($request, $bill);
return response()->json($bill->load(['patient', 'lineItems', 'payments']));
}
public function recordPayment(Request $request, Bill $bill): JsonResponse
{
$this->authorizeAbility($request, 'payments.manage');
$this->authorizeBill($request, $bill);
$payment = $this->bills->recordPayment(
$bill,
$this->ownerRef($request),
$request->validate([
'amount_minor' => ['required', 'integer', 'min:1'],
'method' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.payment_methods')))],
'reference' => ['nullable', 'string', 'max:100'],
]),
$this->ownerRef($request),
);
return response()->json($payment, 201);
}
protected function authorizeBill(Request $request, Bill $bill): void
{
$this->authorizeOwner($request, $bill);
abort_unless($bill->organization_id === $this->organization($request)->id, 404);
}
protected function authorizeVisit(Request $request, Visit $visit): void
{
$this->authorizeOwner($request, $visit);
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Api\Concerns;
use App\Models\Member;
use App\Models\Organization;
use App\Services\Care\CarePermissions;
use App\Services\Care\OrganizationResolver;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
trait ScopesApiToAccount
{
protected function ownerRef(Request $request): string
{
return (string) $request->user()->public_id;
}
protected function organization(Request $request): Organization
{
$organization = app(OrganizationResolver::class)->resolveForUser($request->user());
abort_unless($organization, 404);
return $organization;
}
protected function member(Request $request): ?Member
{
return app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
}
protected function authorizeAbility(Request $request, string $ability): void
{
abort_unless(
app(CarePermissions::class)->can($this->member($request), $ability),
403,
);
}
protected function authorizeOwner(Request $request, Model $model): void
{
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
}
}
@@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Models\Appointment;
use App\Models\Consultation;
use App\Services\Care\AppointmentService;
use App\Services\Care\ConsultationService;
use App\Services\Care\OrganizationResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ConsultationController extends Controller
{
use ScopesApiToAccount;
public function __construct(
protected ConsultationService $consultations,
protected AppointmentService $appointments,
) {}
public function show(Request $request, Consultation $consultation): JsonResponse
{
$this->authorizeAbility($request, 'consultations.view');
$this->authorizeConsultation($request, $consultation);
return response()->json($consultation->load([
'patient', 'practitioner', 'visit', 'appointment',
'vitalSigns', 'diagnoses', 'documents',
]));
}
public function start(Request $request, Appointment $appointment): JsonResponse
{
$this->authorizeAbility($request, 'consultations.manage');
$this->authorizeAppointment($request, $appointment);
$practitionerId = $request->input('practitioner_id')
? (int) $request->input('practitioner_id')
: $appointment->practitioner_id;
$this->appointments->startConsultation(
$appointment,
$this->ownerRef($request),
$practitionerId,
$this->ownerRef($request),
);
$consultation = $this->consultations->startFromAppointment(
$appointment->fresh(),
$this->ownerRef($request),
$this->ownerRef($request),
);
return response()->json($consultation, 201);
}
public function update(Request $request, Consultation $consultation): JsonResponse
{
$this->authorizeAbility($request, 'consultations.manage');
$this->authorizeConsultation($request, $consultation);
$updated = $this->consultations->save(
$consultation,
$this->ownerRef($request),
$this->validatedConsultationData($request),
$this->ownerRef($request),
);
return response()->json($updated);
}
public function complete(Request $request, Consultation $consultation): JsonResponse
{
$this->authorizeAbility($request, 'consultations.manage');
$this->authorizeConsultation($request, $consultation);
$completed = $this->consultations->complete(
$consultation,
$this->ownerRef($request),
$this->ownerRef($request),
);
return response()->json($completed);
}
protected function authorizeAppointment(Request $request, Appointment $appointment): void
{
$this->authorizeOwner($request, $appointment);
abort_unless($appointment->organization_id === $this->organization($request)->id, 404);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchScope !== null && $appointment->branch_id !== $branchScope) {
abort(404);
}
}
protected function authorizeConsultation(Request $request, Consultation $consultation): void
{
$this->authorizeOwner($request, $consultation);
$consultation->loadMissing('visit');
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchScope !== null && $consultation->visit->branch_id !== $branchScope) {
abort(404);
}
}
/**
* @return array<string, mixed>
*/
protected function validatedConsultationData(Request $request): array
{
return $request->validate([
'symptoms' => ['nullable', 'string', 'max:10000'],
'clinical_notes' => ['nullable', 'string', 'max:20000'],
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
'vitals' => ['nullable', 'array'],
'vitals.bp_systolic' => ['nullable', 'integer', 'min:50', 'max:300'],
'vitals.bp_diastolic' => ['nullable', 'integer', 'min:30', 'max:200'],
'vitals.pulse' => ['nullable', 'integer', 'min:20', 'max:250'],
'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'],
'diagnoses' => ['nullable', 'array'],
'diagnoses.*.code' => ['nullable', 'string', 'max:50'],
'diagnoses.*.description' => ['nullable', 'string', 'max:500'],
'diagnoses.*.is_primary' => ['nullable', 'boolean'],
'diagnoses.*.notes' => ['nullable', 'string', 'max:2000'],
]);
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Models\Drug;
use App\Models\Prescription;
use App\Services\Care\PharmacyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DrugController extends Controller
{
use ScopesApiToAccount;
public function __construct(
protected PharmacyService $pharmacy,
) {}
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'pharmacy.view');
$organization = $this->organization($request);
$drugs = $this->pharmacy->listDrugs(
$this->ownerRef($request),
$organization->id,
$request->only(['q', 'per_page']),
);
return response()->json($drugs);
}
public function store(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'pharmacy.manage');
$drug = $this->pharmacy->createDrug(
$this->organization($request),
$this->ownerRef($request),
$request->validate([
'name' => ['required', 'string', 'max:255'],
'generic_name' => ['nullable', 'string', 'max:255'],
'sku' => ['nullable', 'string', 'max:100'],
'unit_price_minor' => ['nullable', 'integer', 'min:0'],
]),
);
return response()->json($drug, 201);
}
public function dispense(Request $request, Prescription $prescription): JsonResponse
{
$this->authorizeAbility($request, 'prescriptions.dispense');
$this->authorizeOwner($request, $prescription);
$updated = $this->pharmacy->dispensePrescription(
$prescription,
$this->ownerRef($request),
$request->input('allocations', []),
$this->ownerRef($request),
);
return response()->json($updated);
}
}
@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Models\Consultation;
use App\Models\InvestigationRequest;
use App\Models\InvestigationType;
use App\Models\Prescription;
use App\Services\Care\InvestigationService;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PrescriptionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class InvestigationController extends Controller
{
use ScopesApiToAccount;
public function __construct(
protected InvestigationService $investigations,
) {}
public function catalog(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'lab.view');
$organization = $this->organization($request);
$types = InvestigationType::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return response()->json(['data' => $types]);
}
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'lab.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$requests = $this->investigations->list(
$this->ownerRef($request),
$organization->id,
$request->only(['status', 'patient_id', 'per_page']),
$branchScope,
);
return response()->json($requests);
}
public function queue(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'lab.manage');
$validated = $request->validate([
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
'status' => ['nullable', 'string'],
]);
$queue = $this->investigations->workQueue(
$this->ownerRef($request),
(int) $validated['branch_id'],
$validated['status'] ?? null,
);
return response()->json(['data' => $queue]);
}
public function store(Request $request, Consultation $consultation): JsonResponse
{
$this->authorizeAbility($request, 'investigations.request');
$this->authorizeConsultation($request, $consultation);
$validated = $request->validate([
'investigation_type_ids' => ['required', 'array', 'min:1'],
'investigation_type_ids.*' => ['integer', 'exists:care_investigation_types,id'],
'clinical_notes' => ['nullable', 'string', 'max:2000'],
'priority' => ['nullable', 'string', 'in:routine,urgent'],
]);
$created = $this->investigations->requestFromConsultation(
$consultation,
$this->ownerRef($request),
$validated['investigation_type_ids'],
$validated['clinical_notes'] ?? null,
$validated['priority'] ?? 'routine',
$this->ownerRef($request),
);
return response()->json(['data' => $created], 201);
}
public function show(Request $request, InvestigationRequest $investigation): JsonResponse
{
$this->authorizeAbility($request, 'lab.view');
$this->authorizeInvestigation($request, $investigation);
return response()->json($investigation->load([
'patient', 'investigationType', 'result.values', 'result.attachments',
]));
}
public function collectSample(Request $request, InvestigationRequest $investigation): JsonResponse
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeInvestigation($request, $investigation);
$updated = $this->investigations->collectSample(
$investigation,
$this->ownerRef($request),
$request->input('sample_barcode'),
$this->ownerRef($request),
);
return response()->json($updated);
}
public function enterResults(Request $request, InvestigationRequest $investigation): JsonResponse
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeInvestigation($request, $investigation);
$validated = $request->validate([
'value' => ['nullable', 'string', 'max:255'],
'result_summary' => ['nullable', 'string', 'max:5000'],
'interpretation' => ['nullable', 'string', 'max:5000'],
'values' => ['nullable', 'array'],
]);
$result = $this->investigations->enterResults(
$investigation,
$this->ownerRef($request),
$validated,
$this->ownerRef($request),
);
return response()->json($result);
}
public function approve(Request $request, InvestigationRequest $investigation): JsonResponse
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeInvestigation($request, $investigation);
$updated = $this->investigations->approve($investigation, $this->ownerRef($request), $this->ownerRef($request));
return response()->json($updated);
}
protected function authorizeConsultation(Request $request, Consultation $consultation): void
{
$this->authorizeOwner($request, $consultation);
$consultation->loadMissing('visit');
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
}
protected function authorizeInvestigation(Request $request, InvestigationRequest $investigation): void
{
$this->authorizeOwner($request, $investigation);
abort_unless($investigation->organization_id === $this->organization($request)->id, 404);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchScope !== null && $investigation->branch_id !== $branchScope) {
abort(404);
}
}
}
@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Models\Patient;
use App\Services\Care\CarePermissions;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PatientService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PatientController extends Controller
{
use ScopesApiToAccount;
public function __construct(
protected PatientService $patients,
) {}
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'patients.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$patients = $this->patients->search(
$this->ownerRef($request),
$organization->id,
$request->only(['q', 'patient_number', 'phone', 'national_id', 'date_of_birth', 'per_page']),
$branchScope,
);
return response()->json($patients);
}
public function store(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'patients.manage');
$organization = $this->organization($request);
$patient = $this->patients->create(
$organization,
$this->ownerRef($request),
$this->validatedPatientData($request),
$this->ownerRef($request),
);
return response()->json($patient, 201);
}
public function show(Request $request, Patient $patient): JsonResponse
{
$this->authorizeAbility($request, 'patients.view');
$this->authorizePatient($request, $patient);
return response()->json($this->patients->dashboard($patient));
}
public function update(Request $request, Patient $patient): JsonResponse
{
$this->authorizeAbility($request, 'patients.manage');
$this->authorizePatient($request, $patient);
$updated = $this->patients->update(
$patient,
$this->ownerRef($request),
$this->validatedPatientData($request),
$this->ownerRef($request),
);
return response()->json($updated);
}
public function destroy(Request $request, Patient $patient): JsonResponse
{
$this->authorizeAbility($request, 'patients.manage');
$this->authorizePatient($request, $patient);
$this->patients->delete($patient, $this->ownerRef($request), $this->ownerRef($request));
return response()->json(['message' => 'Patient archived.']);
}
protected function authorizePatient(Request $request, Patient $patient): void
{
$this->authorizeOwner($request, $patient);
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchScope !== null && $patient->branch_id !== $branchScope) {
abort(404);
}
}
/**
* @return array<string, mixed>
*/
protected function validatedPatientData(Request $request): array
{
return $request->validate([
'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'],
'first_name' => ['required', 'string', 'max:100'],
'last_name' => ['required', 'string', 'max:100'],
'other_names' => ['nullable', 'string', 'max:100'],
'gender' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('care.genders')))],
'date_of_birth' => ['nullable', 'date', 'before:today'],
'phone' => ['nullable', 'string', 'max:30'],
'email' => ['nullable', 'email', 'max:255'],
'national_id' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:500'],
'city' => ['nullable', 'string', 'max:100'],
'region' => ['nullable', 'string', 'max:100'],
'notes' => ['nullable', 'string', 'max:5000'],
'allergies' => ['nullable', 'array'],
'conditions' => ['nullable', 'array'],
'family_history' => ['nullable', 'array'],
'emergency_contacts' => ['nullable', 'array'],
'insurance' => ['nullable', 'array'],
]);
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Models\Consultation;
use App\Models\Prescription;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PrescriptionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PrescriptionController extends Controller
{
use ScopesApiToAccount;
public function __construct(
protected PrescriptionService $prescriptions,
) {}
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'prescriptions.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$prescriptions = $this->prescriptions->list(
$this->ownerRef($request),
$organization->id,
$request->only(['status', 'patient_id', 'per_page']),
$branchScope,
);
return response()->json($prescriptions);
}
public function queue(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'prescriptions.view');
$organization = $this->organization($request);
$queue = $this->prescriptions->pharmacyQueue($this->ownerRef($request), $organization->id);
return response()->json(['data' => $queue]);
}
public function store(Request $request, Consultation $consultation): JsonResponse
{
$this->authorizeAbility($request, 'prescriptions.manage');
$this->authorizeConsultation($request, $consultation);
$prescription = $this->prescriptions->createFromConsultation(
$consultation,
$this->ownerRef($request),
$this->validatedPrescriptionData($request),
$this->ownerRef($request),
);
return response()->json($prescription, 201);
}
public function show(Request $request, Prescription $prescription): JsonResponse
{
$this->authorizeAbility($request, 'prescriptions.view');
$this->authorizePrescription($request, $prescription);
return response()->json($prescription->load(['patient', 'practitioner', 'items']));
}
public function dispense(Request $request, Prescription $prescription): JsonResponse
{
$this->authorizeAbility($request, 'prescriptions.dispense');
$this->authorizePrescription($request, $prescription);
$updated = $this->prescriptions->dispense($prescription, $this->ownerRef($request), $this->ownerRef($request));
return response()->json($updated);
}
protected function authorizeConsultation(Request $request, Consultation $consultation): void
{
$this->authorizeOwner($request, $consultation);
$consultation->loadMissing('visit');
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
}
protected function authorizePrescription(Request $request, Prescription $prescription): void
{
$this->authorizeOwner($request, $prescription);
abort_unless($prescription->organization_id === $this->organization($request)->id, 404);
}
/**
* @return array<string, mixed>
*/
protected function validatedPrescriptionData(Request $request): array
{
return $request->validate([
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
'notes' => ['nullable', 'string', 'max:5000'],
'activate' => ['nullable', 'boolean'],
'items' => ['required', 'array', 'min:1'],
'items.*.is_procedure' => ['nullable', 'boolean'],
'items.*.name' => ['required', 'string', 'max:255'],
'items.*.dosage' => ['nullable', 'string', 'max:100'],
'items.*.frequency' => ['nullable', 'string', 'max:100'],
'items.*.duration' => ['nullable', 'string', 'max:100'],
'items.*.route' => ['nullable', 'string', 'max:50'],
'items.*.quantity' => ['nullable', 'string', 'max:50'],
'items.*.instructions' => ['nullable', 'string', 'max:2000'],
]);
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Services\Care\AppointmentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class QueueController extends Controller
{
use ScopesApiToAccount;
public function __construct(
protected AppointmentService $appointments,
) {}
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'appointments.view');
$validated = $request->validate([
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
]);
$queue = $this->appointments->queue(
$this->ownerRef($request),
(int) $validated['branch_id'],
isset($validated['practitioner_id']) ? (int) $validated['practitioner_id'] : null,
);
return response()->json(['data' => $queue]);
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api;
use App\Events\ServiceEventOccurred;
use App\Http\Controllers\Controller;
use App\Services\Events\ServiceEventSignature;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class ServiceEventController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$secret = (string) config('service_events.inbound_secret');
$signature = (string) $request->header('X-Ladill-Signature', '');
if (! ServiceEventSignature::verify($request->getContent(), $signature, $secret)) {
return response()->json(['error' => 'invalid signature'], 401);
}
$eventId = (string) $request->header('X-Ladill-Event-Id', (string) $request->input('id', ''));
if ($eventId !== '') {
if (Cache::has("svcevt:{$eventId}")) {
return response()->json(['status' => 'duplicate']);
}
Cache::put("svcevt:{$eventId}", true, now()->addDay());
}
event(new ServiceEventOccurred((string) $request->input('event'), (array) $request->input('data', [])));
return response()->json(['status' => 'accepted']);
}
}
@@ -0,0 +1,293 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Client\Response as HttpResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* "Sign in with Ladill" Authorization Code + PKCE against auth.ladill.com.
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
*/
class SsoLoginController extends Controller
{
/**
* Max consecutive failed callbacks before we stop bouncing back into the
* authorize flow and show an error page instead (prevents an infinite
* /sso/callback /sso/connect redirect loop on a persistent upstream error).
*/
private const MAX_SSO_ATTEMPTS = 3;
public function connect(Request $request): RedirectResponse|View
{
$intended = (string) $request->query('redirect', route('care.dashboard'));
if (Auth::check()) {
return $this->safeRedirect($intended, route('care.dashboard'));
}
if (! $request->boolean('fallback')) {
$request->session()->forget('sso.attempts');
}
if ($this->attemptSilentRefresh($request, $intended)) {
return $this->safeRedirect($intended, route('care.dashboard'));
}
$verifier = Str::random(64);
$state = Str::random(40);
$request->session()->put('sso.verifier', $verifier);
$request->session()->put('sso.state', $state);
$request->session()->put('sso.intended', $intended);
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$query = [
'response_type' => 'code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'scope' => 'openid profile email',
'state' => $state,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
];
$loginHint = (string) $request->session()->get('sso.login_hint', '');
if ($loginHint !== '') {
$query['login_hint'] = $loginHint;
}
if (! $request->boolean('interactive')) {
$query['prompt'] = 'none';
}
$authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query);
return redirect()->away($authorizeUrl);
}
public function callback(Request $request): RedirectResponse
{
$intended = (string) $request->session()->get('sso.intended', route('care.dashboard'));
if ($request->filled('error')) {
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
&& ! $request->boolean('interactive')) {
return redirect()->away((string) config('ladill.marketing_url'));
}
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')));
}
if (! $request->filled('code')
|| $request->query('state') !== $request->session()->pull('sso.state')) {
return $this->finishCallback($request, $intended, 'invalid_state');
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'authorization_code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'code' => (string) $request->query('code'),
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
]);
if ($tokenRes->failed()) {
return $this->finishCallback($request, $intended, 'token_exchange_failed');
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return $this->finishCallback($request, $intended, 'userinfo_failed');
}
Auth::login($user, remember: true);
$request->session()->regenerate();
$request->session()->forget('sso.attempts');
return $this->finishCallback($request, $intended, null);
}
public function failed(Request $request): View
{
return view('auth.sso-error', [
'reason' => (string) $request->session()->get('sso.error', ''),
]);
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
// Per-app sign-out: end only this app's session and keep the platform
// (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently.
return redirect()->route('care.signed-out');
}
/** Platform session ended — clear this app and offer silent sign-in again. */
public function platformSignedOut(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => (string) $request->query('redirect', ''),
]);
}
public function logoutBridge(Request $request): View
{
$return = $this->safeReturnUrl((string) $request->query('return', ''));
$hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([
'embedded' => 1,
'return' => $return,
]);
return view('auth.sso-logout-bridge', [
'hubUrl' => $hubUrl,
'return' => $return,
'authOrigin' => 'https://'.config('app.auth_domain'),
]);
}
public function frontchannelLogout(Request $request): Response|RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$return = (string) $request->query('return', '');
$root = (string) config('app.platform_domain', 'ladill.com');
$host = parse_url($return, PHP_URL_HOST);
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($return);
}
return response('', 204);
}
private function attemptSilentRefresh(Request $request, string $intended): bool
{
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
if ($refreshToken === '') {
return false;
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'scope' => 'openid profile email',
]);
if ($tokenRes->failed()) {
$request->session()->forget('sso.refresh_token');
return false;
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return false;
}
Auth::login($user, remember: true);
$request->session()->put('sso.intended', $intended);
return true;
}
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
{
$refreshToken = (string) $tokenRes->json('refresh_token', '');
if ($refreshToken !== '') {
$request->session()->put('sso.refresh_token', $refreshToken);
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
if ($claims->failed() || ! $claims->json('sub')) {
return null;
}
$email = (string) ($claims->json('email') ?: '');
if ($email !== '') {
$request->session()->put('sso.login_hint', $email);
}
return User::updateOrCreate(
['public_id' => (string) $claims->json('sub')],
[
'name' => $claims->json('name'),
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
'avatar_url' => $claims->json('picture'),
],
);
}
private function finishCallback(Request $request, string $intended, ?string $error = null): RedirectResponse
{
if ($error) {
$attempts = (int) $request->session()->get('sso.attempts', 0) + 1;
if ($attempts >= self::MAX_SSO_ATTEMPTS) {
$request->session()->forget(['sso.attempts', 'sso.state', 'sso.verifier', 'sso.intended']);
$request->session()->flash('sso.error', $error);
return redirect()->route('sso.failed');
}
$request->session()->put('sso.attempts', $attempts);
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
'fallback' => 1,
]);
}
return $this->safeRedirect($intended, route('care.dashboard'));
}
private function safeReturnUrl(string $url): string
{
$host = parse_url($url, PHP_URL_HOST);
$root = (string) config('app.platform_domain', 'ladill.com');
if (is_string($host) && str_starts_with($url, 'https://')
&& ($host === $root || str_ends_with($host, '.'.$root))) {
return $url;
}
return route('care.signed-out');
}
private function safeRedirect(string $url, string $fallback): RedirectResponse
{
$host = parse_url($url, PHP_URL_HOST);
$root = (string) config('app.platform_domain', 'ladill.com');
if (is_string($host) && str_starts_with($url, 'https://')
&& ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($url);
}
return redirect()->away($fallback);
}
}
@@ -0,0 +1,242 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Patient;
use App\Models\Practitioner;
use App\Services\Care\AppointmentService;
use App\Services\Care\OrganizationResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AppointmentController extends Controller
{
use ScopesToAccount;
public function __construct(
protected AppointmentService $appointments,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'appointments.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$appointments = $this->appointments->list(
$this->ownerRef($request),
$organization->id,
$request->only(['status', 'practitioner_id', 'date', 'patient_id']),
$branchScope,
);
$practitioners = $this->activePractitioners($request, $organization->id);
return view('care.appointments.index', [
'organization' => $organization,
'appointments' => $appointments,
'practitioners' => $practitioners,
'statuses' => config('care.appointment_statuses'),
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'appointments.manage');
$organization = $this->organization($request);
return view('care.appointments.create', $this->formData($request, $organization));
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$organization = $this->organization($request);
$validated = $this->validatedAppointmentData($request);
$appointment = $this->appointments->book(
$organization,
$this->ownerRef($request),
$validated,
$this->ownerRef($request),
);
return redirect()->route('care.appointments.show', $appointment)
->with('success', 'Appointment booked.');
}
public function walkInCreate(Request $request): View
{
$this->authorizeAbility($request, 'appointments.manage');
$organization = $this->organization($request);
return view('care.appointments.walk-in', $this->formData($request, $organization));
}
public function walkInStore(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$organization = $this->organization($request);
$validated = $this->validatedWalkInData($request);
$appointment = $this->appointments->walkIn(
$organization,
$this->ownerRef($request),
$validated,
$this->ownerRef($request),
);
return redirect()->route('care.queue.index')
->with('success', 'Walk-in added to queue.');
}
public function show(Request $request, Appointment $appointment): View
{
$this->authorizeAbility($request, 'appointments.view');
$this->authorizeAppointment($request, $appointment);
$appointment->load(['patient', 'practitioner', 'branch', 'department', 'visit', 'consultation']);
$canManage = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'appointments.manage');
$canConsult = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'consultations.manage');
return view('care.appointments.show', [
'appointment' => $appointment,
'statuses' => config('care.appointment_statuses'),
'canManage' => $canManage,
'canConsult' => $canConsult,
]);
}
public function checkIn(Request $request, Appointment $appointment): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeAppointment($request, $appointment);
$this->appointments->checkIn($appointment, $this->ownerRef($request), $this->ownerRef($request));
return redirect()->route('care.queue.index')
->with('success', 'Patient checked in and added to queue.');
}
public function cancel(Request $request, Appointment $appointment): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeAppointment($request, $appointment);
$this->appointments->cancel($appointment, $this->ownerRef($request), $this->ownerRef($request));
return back()->with('success', 'Appointment cancelled.');
}
public function noShow(Request $request, Appointment $appointment): RedirectResponse
{
$this->authorizeAbility($request, 'appointments.manage');
$this->authorizeAppointment($request, $appointment);
$this->appointments->markNoShow($appointment, $this->ownerRef($request), $this->ownerRef($request));
return back()->with('success', 'Marked as no-show.');
}
protected function authorizeAppointment(Request $request, Appointment $appointment): void
{
$this->authorizeOwner($request, $appointment);
abort_unless($appointment->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $appointment->branch_id !== $branchId) {
abort(404);
}
}
/**
* @return array<string, mixed>
*/
protected function formData(Request $request, \App\Models\Organization $organization): array
{
$ownerRef = $this->ownerRef($request);
$branchQuery = Branch::owned($ownerRef)->where('organization_id', $organization->id)->where('is_active', true);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchScope !== null) {
$branchQuery->where('id', $branchScope);
}
$branches = $branchQuery->orderBy('name')->get();
$defaultBranch = $branchScope ?? $branches->first()?->id;
$patients = Patient::owned($ownerRef)
->where('organization_id', $organization->id)
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
->orderBy('first_name')
->limit(200)
->get();
$departments = Department::owned($ownerRef)
->whereIn('branch_id', $branches->pluck('id'))
->where('is_active', true)
->orderBy('name')
->get();
return [
'organization' => $organization,
'branches' => $branches,
'defaultBranch' => $defaultBranch,
'patients' => $patients,
'practitioners' => $this->activePractitioners($request, $organization->id),
'departments' => $departments,
];
}
/**
* @return \Illuminate\Database\Eloquent\Collection<int, Practitioner>
*/
protected function activePractitioners(Request $request, int $organizationId)
{
return Practitioner::owned($this->ownerRef($request))
->where('organization_id', $organizationId)
->where('is_active', true)
->orderBy('name')
->get();
}
/**
* @return array<string, mixed>
*/
protected function validatedAppointmentData(Request $request): array
{
return $request->validate([
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
'patient_id' => ['required', 'integer', 'exists:care_patients,id'],
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
'department_id' => ['nullable', 'integer', 'exists:care_departments,id'],
'scheduled_at' => ['required', 'date', 'after:now'],
'reason' => ['nullable', 'string', 'max:1000'],
'notes' => ['nullable', 'string', 'max:5000'],
]);
}
/**
* @return array<string, mixed>
*/
protected function validatedWalkInData(Request $request): array
{
return $request->validate([
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
'patient_id' => ['required', 'integer', 'exists:care_patients,id'],
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
'department_id' => ['nullable', 'integer', 'exists:care_departments,id'],
'reason' => ['nullable', 'string', 'max:1000'],
'notes' => ['nullable', 'string', 'max:5000'],
]);
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\AuditLog;
use App\Services\Care\CarePermissions;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AuditLogController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'audit.view');
$organization = $this->organization($request);
$logs = $this->query($request, $organization)->paginate(50)->withQueryString();
return view('care.audit.index', [
'logs' => $logs,
'organization' => $organization,
'actions' => config('care.audit_actions'),
'canExport' => app(CarePermissions::class)->can($this->member($request), 'audit.export'),
]);
}
public function export(Request $request): StreamedResponse
{
$this->authorizeAbility($request, 'audit.export');
$organization = $this->organization($request);
$filename = 'care-audit-'.now()->format('Y-m-d-His').'.csv';
return response()->streamDownload(function () use ($request, $organization) {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['Time', 'Action', 'Actor', 'Subject', 'Metadata', 'IP']);
$this->query($request, $organization)->chunk(200, function ($logs) use ($handle) {
foreach ($logs as $log) {
fputcsv($handle, [
$log->created_at?->toDateTimeString(),
$log->action,
$log->actor_ref ?? '—',
$log->subject_type ? class_basename($log->subject_type).' #'.$log->subject_id : '—',
json_encode($log->metadata ?? []),
$log->ip_address ?? '—',
]);
}
});
fclose($handle);
}, $filename, ['Content-Type' => 'text/csv']);
}
protected function query(Request $request, $organization)
{
return AuditLog::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->when($request->action, fn ($q, $action) => $q->where('action', $action))
->when($request->from, fn ($q, $from) => $q->where('created_at', '>=', Carbon::parse($from)->startOfDay()))
->when($request->to, fn ($q, $to) => $q->where('created_at', '<=', Carbon::parse($to)->endOfDay()))
->when($request->q, function ($q, $search) {
$q->where(function ($inner) use ($search) {
$inner->where('action', 'like', "%{$search}%")
->orWhere('metadata', 'like', "%{$search}%");
});
})
->orderByDesc('created_at');
}
}
@@ -0,0 +1,176 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Bill;
use App\Models\Visit;
use App\Services\Care\BillService;
use App\Services\Care\OrganizationResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BillController extends Controller
{
use ScopesToAccount;
public function __construct(
protected BillService $bills,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'bills.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$bills = $this->bills->list(
$this->ownerRef($request),
$organization->id,
$request->only(['status', 'patient_id']),
$branchScope,
);
return view('care.bills.index', [
'organization' => $organization,
'bills' => $bills,
'statuses' => config('care.bill_statuses'),
]);
}
public function generate(Request $request, Visit $visit): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
$this->authorizeVisit($request, $visit);
$bill = $this->bills->generateFromVisit($visit, $this->ownerRef($request), $this->ownerRef($request));
return redirect()->route('care.bills.show', $bill)
->with('success', 'Bill generated from visit.');
}
public function show(Request $request, Bill $bill): View
{
$this->authorizeAbility($request, 'bills.view');
$this->authorizeBill($request, $bill);
$bill->load(['patient', 'branch', 'visit', 'lineItems', 'payments']);
$canManage = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'bills.manage');
$canPay = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'payments.manage');
return view('care.bills.show', [
'bill' => $bill,
'statuses' => config('care.bill_statuses'),
'lineTypes' => config('care.bill_line_types'),
'paymentMethods' => config('care.payment_methods'),
'canManage' => $canManage,
'canPay' => $canPay,
]);
}
public function addLineItem(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
$this->authorizeBill($request, $bill);
$this->bills->addManualLineItem(
$bill,
$this->ownerRef($request),
$request->validate([
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.bill_line_types')))],
'description' => ['required', 'string', 'max:255'],
'quantity' => ['required', 'integer', 'min:1'],
'unit_price_minor' => ['required', 'integer', 'min:0'],
]),
$this->ownerRef($request),
);
return back()->with('success', 'Line item added.');
}
public function applyAdjustments(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
$this->authorizeBill($request, $bill);
$validated = $request->validate([
'discount_minor' => ['nullable', 'integer', 'min:0'],
'tax_minor' => ['nullable', 'integer', 'min:0'],
]);
$this->bills->applyAdjustments(
$bill,
$this->ownerRef($request),
(int) ($validated['discount_minor'] ?? 0),
(int) ($validated['tax_minor'] ?? 0),
$this->ownerRef($request),
);
return back()->with('success', 'Bill updated.');
}
public function recordPayment(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'payments.manage');
$this->authorizeBill($request, $bill);
$this->bills->recordPayment(
$bill,
$this->ownerRef($request),
$request->validate([
'amount_minor' => ['required', 'integer', 'min:1'],
'method' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.payment_methods')))],
'reference' => ['nullable', 'string', 'max:100'],
'notes' => ['nullable', 'string', 'max:500'],
]),
$this->ownerRef($request),
);
return back()->with('success', 'Payment recorded.');
}
public function void(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
$this->authorizeBill($request, $bill);
$this->bills->void($bill, $this->ownerRef($request), $this->ownerRef($request));
return redirect()->route('care.bills.index')->with('success', 'Bill voided.');
}
public function print(Request $request, Bill $bill): View
{
$this->authorizeAbility($request, 'bills.view');
$this->authorizeBill($request, $bill);
$bill->load(['patient', 'branch', 'lineItems', 'payments', 'organization']);
return view('care.bills.print', [
'bill' => $bill,
'organization' => $this->organization($request),
]);
}
protected function authorizeBill(Request $request, Bill $bill): void
{
$this->authorizeOwner($request, $bill);
abort_unless($bill->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $bill->branch_id !== $branchId) {
abort(404);
}
}
protected function authorizeVisit(Request $request, Visit $visit): void
{
$this->authorizeOwner($request, $visit);
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
}
}
@@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Services\Care\AuditLogger;
use App\Services\Care\PlanService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BranchController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'admin.branches.view');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->withCount('departments')
->orderBy('name')
->get();
return view('care.admin.branches.index', compact('branches', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'admin.branches.manage');
return view('care.admin.branches.create', ['organization' => $this->organization($request)]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$currentCount = Branch::owned($owner)
->where('organization_id', $organization->id)
->count();
if (! app(PlanService::class)->canAddBranch($organization, $currentCount)) {
return back()->withInput()->with('error', 'Your plan allows one branch. Upgrade to Care Pro for unlimited branches.');
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:500'],
'phone' => ['nullable', 'string', 'max:50'],
]);
$branch = Branch::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
...$validated,
'is_active' => true,
]);
AuditLogger::record($owner, 'branch.created', $organization->id, $owner, Branch::class, $branch->id);
return redirect()->route('care.branches.index')->with('success', 'Branch created.');
}
public function edit(Request $request, Branch $branch): View
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch);
return view('care.admin.branches.edit', compact('branch'));
}
public function update(Request $request, Branch $branch): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:500'],
'phone' => ['nullable', 'string', 'max:50'],
'is_active' => ['boolean'],
]);
$branch->update([
...$validated,
'is_active' => $request->boolean('is_active', true),
]);
AuditLogger::record(
$this->ownerRef($request),
'branch.updated',
$branch->organization_id,
$this->ownerRef($request),
Branch::class,
$branch->id,
);
return redirect()->route('care.branches.index')->with('success', 'Branch updated.');
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Care\Concerns;
use App\Models\Member;
use App\Models\Organization;
use App\Services\Care\CarePermissions;
use App\Services\Care\OrganizationResolver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
trait ScopesToAccount
{
protected function ownerRef(Request $request): string
{
return (string) $request->user()->public_id;
}
protected function organization(Request $request): Organization
{
$organization = $request->attributes->get('care.organization')
?? app(OrganizationResolver::class)->resolveForUser($request->user());
abort_unless($organization, 404);
return $organization;
}
protected function member(Request $request): ?Member
{
return $request->attributes->get('care.member')
?? app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
}
protected function authorizeAbility(Request $request, string $ability): void
{
abort_unless(
app(CarePermissions::class)->can($this->member($request), $ability),
403,
);
}
protected function authorizeOwner(Request $request, Model $model): void
{
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
}
protected function scopeToBranch(Request $request, Builder $query, string $column = 'branch_id'): Builder
{
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null) {
$query->where($column, $branchId);
}
return $query;
}
}
@@ -0,0 +1,158 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Consultation;
use App\Models\InvestigationType;
use App\Models\Practitioner;
use App\Services\Care\ConsultationService;
use App\Services\Care\OrganizationResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ConsultationController extends Controller
{
use ScopesToAccount;
public function __construct(
protected ConsultationService $consultations,
) {}
public function show(Request $request, Consultation $consultation): View
{
$this->authorizeAbility($request, 'consultations.view');
$this->authorizeConsultation($request, $consultation);
$consultation->load([
'patient', 'practitioner', 'visit', 'appointment',
'vitalSigns', 'diagnoses', 'documents',
'investigationRequests.investigationType', 'prescriptions.items',
]);
$canManage = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'consultations.manage');
$canVitals = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'vitals.manage');
$canRequestInvestigations = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'investigations.request');
$canPrescribe = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'prescriptions.manage');
$canGenerateBill = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'bills.manage');
$practitioners = Practitioner::owned($this->ownerRef($request))
->where('organization_id', $this->organization($request)->id)
->where('is_active', true)
->orderBy('name')
->get();
$investigationTypes = InvestigationType::owned($this->ownerRef($request))
->where('organization_id', $this->organization($request)->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('care.consultations.show', [
'consultation' => $consultation,
'practitioners' => $practitioners,
'investigationTypes' => $investigationTypes,
'canManage' => $canManage,
'canVitals' => $canVitals,
'canRequestInvestigations' => $canRequestInvestigations,
'canPrescribe' => $canPrescribe,
'canGenerateBill' => $canGenerateBill,
'isCompleted' => $consultation->status === Consultation::STATUS_COMPLETED,
]);
}
public function update(Request $request, Consultation $consultation): RedirectResponse
{
$canManage = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'consultations.manage');
$canVitals = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'vitals.manage');
abort_unless($canManage || $canVitals, 403);
$this->authorizeConsultation($request, $consultation);
abort_if($consultation->status === Consultation::STATUS_COMPLETED, 422);
$validated = $this->validatedConsultationData($request, $canManage, $canVitals);
$this->consultations->save(
$consultation,
$this->ownerRef($request),
$validated,
$this->ownerRef($request),
);
return back()->with('success', 'Consultation saved.');
}
public function complete(Request $request, Consultation $consultation): RedirectResponse
{
$this->authorizeAbility($request, 'consultations.manage');
$this->authorizeConsultation($request, $consultation);
$this->consultations->complete(
$consultation,
$this->ownerRef($request),
$this->ownerRef($request),
);
return redirect()->route('care.patients.show', $consultation->patient)
->with('success', 'Consultation completed.');
}
protected function authorizeConsultation(Request $request, Consultation $consultation): void
{
$this->authorizeOwner($request, $consultation);
$consultation->loadMissing('visit');
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $consultation->visit->branch_id !== $branchId) {
abort(404);
}
}
/**
* @return array<string, mixed>
*/
protected function validatedConsultationData(Request $request, bool $canManage, bool $canVitals): array
{
$rules = [];
if ($canManage) {
$rules = array_merge($rules, [
'symptoms' => ['nullable', 'string', 'max:10000'],
'clinical_notes' => ['nullable', 'string', 'max:20000'],
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
'diagnoses' => ['nullable', 'array'],
'diagnoses.*.code' => ['nullable', 'string', 'max:50'],
'diagnoses.*.description' => ['nullable', 'string', 'max:500'],
'diagnoses.*.is_primary' => ['nullable', 'boolean'],
'diagnoses.*.notes' => ['nullable', 'string', 'max:2000'],
'documents' => ['nullable', 'array'],
'documents.*' => ['file', 'max:10240', 'mimes:pdf,jpeg,png,jpg,webp'],
]);
$canVitals = true;
}
if ($canVitals) {
$rules['vitals'] = ['nullable', 'array'];
$rules['vitals.bp_systolic'] = ['nullable', 'integer', 'min:50', 'max:300'];
$rules['vitals.bp_diastolic'] = ['nullable', 'integer', 'min:30', 'max:200'];
$rules['vitals.pulse'] = ['nullable', 'integer', 'min:20', 'max:250'];
$rules['vitals.temperature'] = ['nullable', 'numeric', 'min:30', 'max:45'];
$rules['vitals.weight_kg'] = ['nullable', 'numeric', 'min:0', 'max:500'];
$rules['vitals.height_cm'] = ['nullable', 'numeric', 'min:0', 'max:300'];
$rules['vitals.spo2'] = ['nullable', 'integer', 'min:50', 'max:100'];
$rules['vitals.respiratory_rate'] = ['nullable', 'integer', 'min:5', 'max:80'];
}
return $request->validate($rules);
}
}
@@ -0,0 +1,50 @@
<?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\Member;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\ReportService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DashboardController extends Controller
{
use ScopesToAccount;
public function __construct(
protected ReportService $reports,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'dashboard.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$branchQuery = Branch::owned($owner)->where('organization_id', $organization->id);
$this->scopeToBranch($request, $branchQuery);
$stats = [
'branches' => (clone $branchQuery)->where('is_active', true)->count(),
'team_members' => Member::owned($owner)->where('organization_id', $organization->id)->count(),
'departments' => $organization->branches()
->when(app(OrganizationResolver::class)->branchScope($this->member($request)), function ($q, $branchId) {
$q->where('id', $branchId);
})
->withCount('departments')
->get()
->sum('departments_count'),
];
$branches = (clone $branchQuery)->withCount('departments')->orderBy('name')->get();
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$operational = $this->reports->dashboardStats($owner, $organization->id, $branchScope);
return view('care.dashboard', compact('organization', 'stats', 'branches', 'operational'));
}
}
@@ -0,0 +1,144 @@
<?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\Department;
use App\Services\Care\AuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DepartmentController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'admin.departments.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$departments = Department::owned($owner)
->whereHas('branch', fn ($q) => $q->where('organization_id', $organization->id))
->with('branch')
->orderBy('name')
->get();
return view('care.admin.departments.index', [
'departments' => $departments,
'organization' => $organization,
'types' => config('care.department_types'),
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'admin.departments.manage');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('care.admin.departments.create', [
'organization' => $organization,
'branches' => $branches,
'types' => config('care.department_types'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'admin.departments.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$validated = $request->validate([
'branch_id' => ['required', 'integer', 'exists:care_branches,id'],
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.department_types')))],
]);
$branch = Branch::owned($owner)->findOrFail($validated['branch_id']);
abort_unless($branch->organization_id === $organization->id, 404);
$department = Department::create([
'owner_ref' => $owner,
'branch_id' => $branch->id,
'name' => $validated['name'],
'type' => $validated['type'],
'is_active' => true,
]);
AuditLogger::record($owner, 'department.created', $organization->id, $owner, Department::class, $department->id);
return redirect()->route('care.departments.index')->with('success', 'Department created.');
}
public function edit(Request $request, Department $department): View
{
$this->authorizeAbility($request, 'admin.departments.manage');
$this->authorizeOwner($request, $department);
return view('care.admin.departments.edit', [
'department' => $department,
'types' => config('care.department_types'),
]);
}
public function update(Request $request, Department $department): RedirectResponse
{
$this->authorizeAbility($request, 'admin.departments.manage');
$this->authorizeOwner($request, $department);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.department_types')))],
'is_active' => ['boolean'],
]);
$department->update([
...$validated,
'is_active' => $request->boolean('is_active', true),
]);
AuditLogger::record(
$this->ownerRef($request),
'department.updated',
$department->branch->organization_id,
$this->ownerRef($request),
Department::class,
$department->id,
);
return redirect()->route('care.departments.index')->with('success', 'Department updated.');
}
public function destroy(Request $request, Department $department): RedirectResponse
{
$this->authorizeAbility($request, 'admin.departments.manage');
$this->authorizeOwner($request, $department);
$department->load('branch');
$organizationId = $department->branch->organization_id;
$departmentId = $department->id;
$department->delete();
AuditLogger::record(
$this->ownerRef($request),
'department.deleted',
$organizationId,
$this->ownerRef($request),
Department::class,
$departmentId,
);
return redirect()->route('care.departments.index')->with('success', 'Department removed.');
}
}
@@ -0,0 +1,145 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Drug;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PharmacyService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DrugController extends Controller
{
use ScopesToAccount;
public function __construct(
protected PharmacyService $pharmacy,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'pharmacy.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$drugs = $this->pharmacy->listDrugs(
$this->ownerRef($request),
$organization->id,
$request->only(['q']),
$branchScope,
);
$lowStock = $this->pharmacy->lowStock($this->ownerRef($request), $organization->id);
$expired = $this->pharmacy->expiredBatches($this->ownerRef($request), $organization->id);
return view('care.pharmacy.drugs.index', [
'organization' => $organization,
'drugs' => $drugs,
'lowStock' => $lowStock,
'expired' => $expired,
'canManage' => app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'pharmacy.manage'),
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'pharmacy.manage');
return view('care.pharmacy.drugs.create', [
'organization' => $this->organization($request),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'pharmacy.manage');
$drug = $this->pharmacy->createDrug(
$this->organization($request),
$this->ownerRef($request),
$this->validatedDrugData($request),
);
return redirect()->route('care.pharmacy.drugs.show', $drug)
->with('success', 'Drug added to inventory.');
}
public function show(Request $request, Drug $drug): View
{
$this->authorizeAbility($request, 'pharmacy.view');
$this->authorizeDrug($request, $drug);
$drug->load(['batches' => fn ($q) => $q->orderBy('expiry_date')]);
return view('care.pharmacy.drugs.show', [
'drug' => $drug,
'canManage' => app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'pharmacy.manage'),
]);
}
public function edit(Request $request, Drug $drug): View
{
$this->authorizeAbility($request, 'pharmacy.manage');
$this->authorizeDrug($request, $drug);
return view('care.pharmacy.drugs.edit', ['drug' => $drug]);
}
public function update(Request $request, Drug $drug): RedirectResponse
{
$this->authorizeAbility($request, 'pharmacy.manage');
$this->authorizeDrug($request, $drug);
$this->pharmacy->updateDrug($drug, $this->ownerRef($request), $this->validatedDrugData($request));
return redirect()->route('care.pharmacy.drugs.show', $drug)
->with('success', 'Drug updated.');
}
public function receiveBatch(Request $request, Drug $drug): RedirectResponse
{
$this->authorizeAbility($request, 'pharmacy.manage');
$this->authorizeDrug($request, $drug);
$this->pharmacy->receiveBatch(
$drug,
$this->ownerRef($request),
$request->validate([
'batch_number' => ['required', 'string', 'max:100'],
'expiry_date' => ['nullable', 'date', 'after:today'],
'quantity_on_hand' => ['required', 'integer', 'min:1'],
'cost_minor' => ['nullable', 'integer', 'min:0'],
]),
$this->ownerRef($request),
);
return back()->with('success', 'Stock received.');
}
protected function authorizeDrug(Request $request, Drug $drug): void
{
$this->authorizeOwner($request, $drug);
abort_unless($drug->organization_id === $this->organization($request)->id, 404);
}
/**
* @return array<string, mixed>
*/
protected function validatedDrugData(Request $request): array
{
return $request->validate([
'name' => ['required', 'string', 'max:255'],
'generic_name' => ['nullable', 'string', 'max:255'],
'sku' => ['nullable', 'string', 'max:100'],
'unit' => ['nullable', 'string', 'max:50'],
'unit_price_minor' => ['nullable', 'integer', 'min:0'],
'reorder_level' => ['nullable', 'integer', 'min:0'],
'is_active' => ['nullable', 'boolean'],
]);
}
}
@@ -0,0 +1,235 @@
<?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\Consultation;
use App\Models\InvestigationRequest;
use App\Models\InvestigationType;
use App\Models\Member;
use App\Services\Care\InvestigationService;
use App\Services\Care\OrganizationResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class InvestigationController extends Controller
{
use ScopesToAccount;
public function __construct(
protected InvestigationService $investigations,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'lab.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$requests = $this->investigations->list(
$this->ownerRef($request),
$organization->id,
$request->only(['status', 'patient_id']),
$branchScope,
);
return view('care.lab.requests.index', [
'organization' => $organization,
'requests' => $requests,
'statuses' => config('care.investigation_statuses'),
]);
}
public function queue(Request $request): View
{
$this->authorizeAbility($request, 'lab.manage');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->when($branchScope, fn ($q) => $q->where('id', $branchScope))
->orderBy('name')
->get();
$branchId = (int) ($request->input('branch_id') ?: $branchScope ?: $branches->first()?->id);
$status = $request->input('status');
$queue = $branchId
? $this->investigations->workQueue($this->ownerRef($request), $branchId, $status)
: collect();
return view('care.lab.queue.index', [
'organization' => $organization,
'branches' => $branches,
'branchId' => $branchId,
'status' => $status,
'queue' => $queue,
'statuses' => config('care.investigation_statuses'),
'members' => Member::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->orderBy('role')
->get(),
]);
}
public function requestFromConsultation(Request $request, Consultation $consultation): RedirectResponse
{
$this->authorizeAbility($request, 'investigations.request');
$this->authorizeConsultation($request, $consultation);
$validated = $request->validate([
'investigation_type_ids' => ['required', 'array', 'min:1'],
'investigation_type_ids.*' => ['integer', 'exists:care_investigation_types,id'],
'clinical_notes' => ['nullable', 'string', 'max:2000'],
'priority' => ['nullable', 'string', 'in:routine,urgent'],
]);
$this->investigations->requestFromConsultation(
$consultation,
$this->ownerRef($request),
$validated['investigation_type_ids'],
$validated['clinical_notes'] ?? null,
$validated['priority'] ?? 'routine',
$this->ownerRef($request),
);
return back()->with('success', 'Investigation request(s) submitted.');
}
public function show(Request $request, InvestigationRequest $investigation): View
{
$this->authorizeAbility($request, 'lab.view');
$this->authorizeInvestigation($request, $investigation);
$investigation->load([
'patient', 'investigationType', 'practitioner', 'branch',
'result.values', 'result.attachments', 'assignedMember',
]);
$canManage = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'lab.manage');
$canViewResults = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'lab.results.view');
return view('care.lab.requests.show', [
'investigation' => $investigation,
'statuses' => config('care.investigation_statuses'),
'canManage' => $canManage,
'canViewResults' => $canViewResults,
]);
}
public function collectSample(Request $request, InvestigationRequest $investigation): RedirectResponse
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeInvestigation($request, $investigation);
$validated = $request->validate(['sample_barcode' => ['nullable', 'string', 'max:100']]);
$this->investigations->collectSample(
$investigation,
$this->ownerRef($request),
$validated['sample_barcode'] ?? null,
$this->ownerRef($request),
);
return back()->with('success', 'Sample collected.');
}
public function startProcessing(Request $request, InvestigationRequest $investigation): RedirectResponse
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeInvestigation($request, $investigation);
$validated = $request->validate(['assigned_member_id' => ['nullable', 'integer', 'exists:care_members,id']]);
$this->investigations->startProcessing(
$investigation,
$this->ownerRef($request),
$validated['assigned_member_id'] ?? null,
$this->ownerRef($request),
);
return redirect()->route('care.lab.requests.show', $investigation)
->with('success', 'Processing started.');
}
public function enterResults(Request $request, InvestigationRequest $investigation): RedirectResponse
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeInvestigation($request, $investigation);
$validated = $request->validate([
'value' => ['nullable', 'string', 'max:255'],
'result_summary' => ['nullable', 'string', 'max:5000'],
'interpretation' => ['nullable', 'string', 'max:5000'],
'values' => ['nullable', 'array'],
'values.*.parameter' => ['nullable', 'string', 'max:255'],
'values.*.value' => ['nullable', 'string', 'max:255'],
'attachments' => ['nullable', 'array'],
'attachments.*' => ['file', 'max:10240', 'mimes:pdf,jpeg,png,jpg,webp'],
]);
$this->investigations->enterResults(
$investigation,
$this->ownerRef($request),
$validated,
$this->ownerRef($request),
);
return back()->with('success', 'Results submitted for review.');
}
public function approve(Request $request, InvestigationRequest $investigation): RedirectResponse
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeInvestigation($request, $investigation);
$this->investigations->approve($investigation, $this->ownerRef($request), $this->ownerRef($request));
return back()->with('success', 'Results approved.');
}
public function deliver(Request $request, InvestigationRequest $investigation): RedirectResponse
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeInvestigation($request, $investigation);
$this->investigations->deliver($investigation, $this->ownerRef($request), $this->ownerRef($request));
return back()->with('success', 'Results delivered to patient record.');
}
public function cancel(Request $request, InvestigationRequest $investigation): RedirectResponse
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeInvestigation($request, $investigation);
$this->investigations->cancel($investigation, $this->ownerRef($request), $this->ownerRef($request));
return back()->with('success', 'Investigation cancelled.');
}
protected function authorizeConsultation(Request $request, Consultation $consultation): void
{
$this->authorizeOwner($request, $consultation);
$consultation->loadMissing('visit');
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
}
protected function authorizeInvestigation(Request $request, InvestigationRequest $investigation): void
{
$this->authorizeOwner($request, $investigation);
abort_unless($investigation->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $investigation->branch_id !== $branchId) {
abort(404);
}
}
}
@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\InvestigationType;
use App\Services\Care\InvestigationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class InvestigationTypeController extends Controller
{
use ScopesToAccount;
public function __construct(
protected InvestigationService $investigations,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'lab.manage');
$organization = $this->organization($request);
$types = InvestigationType::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->orderBy('category')
->orderBy('name')
->paginate(30);
return view('care.lab.catalog.index', [
'organization' => $organization,
'types' => $types,
'categories' => config('care.investigation_categories'),
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'lab.manage');
return view('care.lab.catalog.create', [
'categories' => config('care.investigation_categories'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'lab.manage');
$type = $this->investigations->createType(
$this->organization($request),
$this->ownerRef($request),
$this->validatedTypeData($request),
);
return redirect()->route('care.lab.catalog.index')
->with('success', "Investigation type \"{$type->name}\" created.");
}
public function edit(Request $request, InvestigationType $investigationType): View
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeType($request, $investigationType);
return view('care.lab.catalog.edit', [
'type' => $investigationType,
'categories' => config('care.investigation_categories'),
]);
}
public function update(Request $request, InvestigationType $investigationType): RedirectResponse
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeType($request, $investigationType);
$this->investigations->updateType($investigationType, $this->ownerRef($request), $this->validatedTypeData($request));
return redirect()->route('care.lab.catalog.index')
->with('success', 'Investigation type updated.');
}
protected function authorizeType(Request $request, InvestigationType $type): void
{
$this->authorizeOwner($request, $type);
abort_unless($type->organization_id === $this->organization($request)->id, 404);
}
/**
* @return array<string, mixed>
*/
protected function validatedTypeData(Request $request): array
{
return $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
'category' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.investigation_categories')))],
'description' => ['nullable', 'string', 'max:2000'],
'unit' => ['nullable', 'string', 'max:50'],
'reference_low' => ['nullable', 'numeric'],
'reference_high' => ['nullable', 'numeric'],
'reference_text' => ['nullable', 'string', 'max:255'],
'price_minor' => ['nullable', 'integer', 'min:0'],
'is_active' => ['nullable', 'boolean'],
]);
}
}
@@ -0,0 +1,98 @@
<?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\Member;
use App\Services\Care\AuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MemberController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'admin.members.view');
$organization = $this->organization($request);
$members = Member::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->with('branch')
->orderBy('created_at')
->get();
return view('care.admin.members.index', [
'members' => $members,
'organization' => $organization,
'roles' => config('care.roles'),
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'admin.members.manage');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('care.admin.members.create', [
'organization' => $organization,
'branches' => $branches,
'roles' => config('care.roles'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'admin.members.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$validated = $request->validate([
'user_ref' => ['required', 'string', 'max:255'],
'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.roles')))],
'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'],
]);
$member = Member::updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $validated['user_ref'],
],
[
'owner_ref' => $owner,
'role' => $validated['role'],
'branch_id' => $validated['branch_id'] ?? null,
],
);
AuditLogger::record($owner, 'member.created', $organization->id, $owner, Member::class, $member->id);
return redirect()->route('care.members.index')->with('success', 'Member saved.');
}
public function destroy(Request $request, Member $member): RedirectResponse
{
$this->authorizeAbility($request, 'admin.members.manage');
$this->authorizeOwner($request, $member);
abort_if($member->user_ref === $this->ownerRef($request), 422, 'You cannot remove yourself.');
$memberId = $member->id;
$organizationId = $member->organization_id;
$member->delete();
AuditLogger::record($this->ownerRef($request), 'member.deleted', $organizationId, $this->ownerRef($request), Member::class, $memberId);
return redirect()->route('care.members.index')->with('success', 'Member removed.');
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Services\Care\OrganizationResolver;
use App\Support\OrganizationBranding;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class OnboardingController extends Controller
{
use ScopesToAccount;
public function __construct(
protected OrganizationResolver $organizations,
) {}
public function show(Request $request): View|RedirectResponse
{
if ($this->organizations->isOnboarded($request->user())) {
return redirect()->route('care.dashboard');
}
return view('care.onboarding.show', [
'user' => $request->user(),
'timezones' => timezone_identifiers_list(),
'facilityTypes' => [
'clinic' => 'Clinic',
'hospital' => 'Hospital',
'diagnostic' => 'Diagnostic laboratory',
'specialist' => 'Specialist practice',
],
]);
}
public function store(Request $request): RedirectResponse
{
if ($this->organizations->isOnboarded($request->user())) {
return redirect()->route('care.dashboard');
}
$validated = $request->validate([
'organization_name' => ['required', 'string', 'max:255'],
'facility_type' => ['required', 'string', 'in:clinic,hospital,diagnostic,specialist'],
'branch_name' => ['required', 'string', 'max:255'],
'branch_address' => ['nullable', 'string', 'max:500'],
'branch_phone' => ['nullable', 'string', 'max:50'],
'timezone' => ['required', 'timezone'],
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
]);
$organization = $this->organizations->completeOnboarding($request->user(), $validated);
if ($request->hasFile('logo')) {
$organization->update([
'logo_path' => OrganizationBranding::storeLogo($organization, $request->file('logo')),
]);
}
return redirect()->route('care.dashboard')->with('success', 'Welcome to Ladill Care!');
}
}
@@ -0,0 +1,200 @@
<?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\Patient;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PatientService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class PatientController extends Controller
{
use ScopesToAccount;
public function __construct(
protected PatientService $patients,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'patients.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$patients = $this->patients->search(
$this->ownerRef($request),
$organization->id,
$request->only(['q', 'patient_number', 'phone', 'national_id', 'date_of_birth']),
$branchScope,
);
return view('care.patients.index', compact('patients', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'patients.manage');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('care.patients.create', [
'organization' => $organization,
'branches' => $branches,
'genders' => config('care.genders'),
'allergySeverities' => config('care.allergy_severities'),
'documentTypes' => config('care.document_types'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'patients.manage');
$organization = $this->organization($request);
$validated = $this->validatedPatientData($request);
$patient = $this->patients->create(
$organization,
$this->ownerRef($request),
$validated,
$this->ownerRef($request),
);
return redirect()->route('care.patients.show', $patient)
->with('success', 'Patient registered successfully.');
}
public function show(Request $request, Patient $patient): View
{
$this->authorizeAbility($request, 'patients.view');
$this->authorizePatient($request, $patient);
$dashboard = $this->patients->dashboard($patient);
$canManage = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'patients.manage');
return view('care.patients.show', array_merge($dashboard, [
'canManage' => $canManage,
'genders' => config('care.genders'),
'allergySeverities' => config('care.allergy_severities'),
'documentTypes' => config('care.document_types'),
]));
}
public function edit(Request $request, Patient $patient): View
{
$this->authorizeAbility($request, 'patients.manage');
$this->authorizePatient($request, $patient);
$organization = $this->organization($request);
$patient->load(['allergies', 'conditions', 'familyHistory', 'emergencyContacts', 'insurancePolicies', 'attachments']);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('care.patients.edit', [
'patient' => $patient,
'organization' => $organization,
'branches' => $branches,
'genders' => config('care.genders'),
'allergySeverities' => config('care.allergy_severities'),
'documentTypes' => config('care.document_types'),
]);
}
public function update(Request $request, Patient $patient): RedirectResponse
{
$this->authorizeAbility($request, 'patients.manage');
$this->authorizePatient($request, $patient);
$validated = $this->validatedPatientData($request);
$this->patients->update($patient, $this->ownerRef($request), $validated, $this->ownerRef($request));
return redirect()->route('care.patients.show', $patient)
->with('success', 'Patient record updated.');
}
public function destroy(Request $request, Patient $patient): RedirectResponse
{
$this->authorizeAbility($request, 'patients.manage');
$this->authorizePatient($request, $patient);
$this->patients->delete($patient, $this->ownerRef($request), $this->ownerRef($request));
return redirect()->route('care.patients.index')
->with('success', 'Patient record archived.');
}
protected function authorizePatient(Request $request, Patient $patient): void
{
$this->authorizeOwner($request, $patient);
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $patient->branch_id !== $branchId) {
abort(404);
}
}
/**
* @return array<string, mixed>
*/
protected function validatedPatientData(Request $request): array
{
return $request->validate([
'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'],
'first_name' => ['required', 'string', 'max:100'],
'last_name' => ['required', 'string', 'max:100'],
'other_names' => ['nullable', 'string', 'max:100'],
'gender' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('care.genders')))],
'date_of_birth' => ['nullable', 'date', 'before:today'],
'phone' => ['nullable', 'string', 'max:30'],
'email' => ['nullable', 'email', 'max:255'],
'national_id' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:500'],
'city' => ['nullable', 'string', 'max:100'],
'region' => ['nullable', 'string', 'max:100'],
'notes' => ['nullable', 'string', 'max:5000'],
'allergies' => ['nullable', 'array'],
'allergies.*.allergen' => ['nullable', 'string', 'max:255'],
'allergies.*.severity' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('care.allergy_severities')))],
'allergies.*.notes' => ['nullable', 'string', 'max:1000'],
'conditions' => ['nullable', 'array'],
'conditions.*.condition' => ['nullable', 'string', 'max:255'],
'conditions.*.onset_date' => ['nullable', 'date'],
'conditions.*.is_chronic' => ['nullable', 'boolean'],
'conditions.*.notes' => ['nullable', 'string', 'max:1000'],
'family_history' => ['nullable', 'array'],
'family_history.*.relation' => ['nullable', 'string', 'max:100'],
'family_history.*.condition' => ['nullable', 'string', 'max:255'],
'family_history.*.notes' => ['nullable', 'string', 'max:1000'],
'emergency_contacts' => ['nullable', 'array'],
'emergency_contacts.*.name' => ['nullable', 'string', 'max:255'],
'emergency_contacts.*.phone' => ['nullable', 'string', 'max:30'],
'emergency_contacts.*.relationship' => ['nullable', 'string', 'max:100'],
'emergency_contacts.*.is_primary' => ['nullable', 'boolean'],
'insurance' => ['nullable', 'array'],
'insurance.*.provider_name' => ['nullable', 'string', 'max:255'],
'insurance.*.policy_number' => ['nullable', 'string', 'max:100'],
'insurance.*.coverage_type' => ['nullable', 'string', 'max:100'],
'insurance.*.expiry_date' => ['nullable', 'date'],
'insurance.*.notes' => ['nullable', 'string', 'max:1000'],
'attachments' => ['nullable', 'array'],
'attachments.*' => ['file', 'max:10240', 'mimes:pdf,jpeg,png,jpg,webp'],
]);
}
}
@@ -0,0 +1,209 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Consultation;
use App\Models\Prescription;
use App\Models\Practitioner;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PharmacyService;
use App\Services\Care\PrescriptionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class PrescriptionController extends Controller
{
use ScopesToAccount;
public function __construct(
protected PrescriptionService $prescriptions,
protected PharmacyService $pharmacy,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'prescriptions.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$prescriptions = $this->prescriptions->list(
$this->ownerRef($request),
$organization->id,
$request->only(['status', 'patient_id']),
$branchScope,
);
return view('care.prescriptions.index', [
'organization' => $organization,
'prescriptions' => $prescriptions,
'statuses' => config('care.prescription_statuses'),
]);
}
public function queue(Request $request): View
{
$this->authorizeAbility($request, 'prescriptions.view');
$organization = $this->organization($request);
$queue = $this->prescriptions->pharmacyQueue($this->ownerRef($request), $organization->id);
$drugs = \App\Models\Drug::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->with(['batches' => fn ($q) => $q->where('quantity_on_hand', '>', 0)->orderBy('expiry_date')])
->orderBy('name')
->get();
$canDispense = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'prescriptions.dispense');
return view('care.prescriptions.queue', [
'organization' => $organization,
'queue' => $queue,
'drugs' => $drugs,
'canDispense' => $canDispense,
]);
}
public function create(Request $request, Consultation $consultation): View
{
$this->authorizeAbility($request, 'prescriptions.manage');
$this->authorizeConsultation($request, $consultation);
$consultation->load(['patient', 'practitioner']);
$practitioners = Practitioner::owned($this->ownerRef($request))
->where('organization_id', $this->organization($request)->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('care.prescriptions.create', [
'consultation' => $consultation,
'practitioners' => $practitioners,
'routes' => config('care.medication_routes'),
]);
}
public function store(Request $request, Consultation $consultation): RedirectResponse
{
$this->authorizeAbility($request, 'prescriptions.manage');
$this->authorizeConsultation($request, $consultation);
$prescription = $this->prescriptions->createFromConsultation(
$consultation,
$this->ownerRef($request),
$this->validatedPrescriptionData($request),
$this->ownerRef($request),
);
return redirect()->route('care.prescriptions.show', $prescription)
->with('success', 'Prescription created.');
}
public function show(Request $request, Prescription $prescription): View
{
$this->authorizeAbility($request, 'prescriptions.view');
$this->authorizePrescription($request, $prescription);
$prescription->load(['patient', 'practitioner', 'items', 'consultation', 'visit']);
$canManage = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'prescriptions.manage');
$canDispense = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'prescriptions.dispense');
return view('care.prescriptions.show', [
'prescription' => $prescription,
'statuses' => config('care.prescription_statuses'),
'routes' => config('care.medication_routes'),
'canManage' => $canManage,
'canDispense' => $canDispense,
]);
}
public function activate(Request $request, Prescription $prescription): RedirectResponse
{
$this->authorizeAbility($request, 'prescriptions.manage');
$this->authorizePrescription($request, $prescription);
$this->prescriptions->activate($prescription, $this->ownerRef($request), $this->ownerRef($request));
return back()->with('success', 'Prescription activated.');
}
public function dispense(Request $request, Prescription $prescription): RedirectResponse
{
$this->authorizeAbility($request, 'prescriptions.dispense');
$this->authorizePrescription($request, $prescription);
$allocations = $request->input('allocations', []);
if (! empty($allocations)) {
$this->pharmacy->dispensePrescription(
$prescription,
$this->ownerRef($request),
$allocations,
$this->ownerRef($request),
);
} else {
$this->prescriptions->dispense($prescription, $this->ownerRef($request), $this->ownerRef($request));
}
return redirect()->route('care.prescriptions.queue')
->with('success', 'Prescription dispensed.');
}
public function cancel(Request $request, Prescription $prescription): RedirectResponse
{
$this->authorizeAbility($request, 'prescriptions.manage');
$this->authorizePrescription($request, $prescription);
$this->prescriptions->cancel($prescription, $this->ownerRef($request), $this->ownerRef($request));
return back()->with('success', 'Prescription cancelled.');
}
protected function authorizeConsultation(Request $request, Consultation $consultation): void
{
$this->authorizeOwner($request, $consultation);
$consultation->loadMissing('visit');
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
}
protected function authorizePrescription(Request $request, Prescription $prescription): void
{
$this->authorizeOwner($request, $prescription);
abort_unless($prescription->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null) {
$prescription->loadMissing('visit');
abort_unless($prescription->visit->branch_id === $branchId, 404);
}
}
/**
* @return array<string, mixed>
*/
protected function validatedPrescriptionData(Request $request): array
{
return $request->validate([
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
'notes' => ['nullable', 'string', 'max:5000'],
'activate' => ['nullable', 'boolean'],
'items' => ['required', 'array', 'min:1'],
'items.*.is_procedure' => ['nullable', 'boolean'],
'items.*.name' => ['required', 'string', 'max:255'],
'items.*.dosage' => ['nullable', 'string', 'max:100'],
'items.*.frequency' => ['nullable', 'string', 'max:100'],
'items.*.duration' => ['nullable', 'string', 'max:100'],
'items.*.route' => ['nullable', 'string', 'max:50'],
'items.*.quantity' => ['nullable', 'string', 'max:50'],
'items.*.instructions' => ['nullable', 'string', 'max:2000'],
]);
}
}
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\Practitioner;
use App\Services\Care\AppointmentService;
use App\Services\Care\ConsultationService;
use App\Services\Care\OrganizationResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class QueueController extends Controller
{
use ScopesToAccount;
public function __construct(
protected AppointmentService $appointments,
protected ConsultationService $consultations,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'appointments.view');
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->when($branchScope, fn ($q) => $q->where('id', $branchScope))
->orderBy('name')
->get();
$branchId = (int) ($request->input('branch_id') ?: $branchScope ?: $branches->first()?->id);
$practitionerId = $request->input('practitioner_id') ? (int) $request->input('practitioner_id') : null;
$queue = $branchId
? $this->appointments->queue($this->ownerRef($request), $branchId, $practitionerId)
: collect();
$inConsultation = Appointment::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('status', Appointment::STATUS_IN_CONSULTATION)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($practitionerId, fn ($q) => $q->where('practitioner_id', $practitionerId))
->with(['patient', 'practitioner', 'consultation'])
->orderBy('started_at')
->get();
$canManageQueue = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'queue.manage');
$canConsult = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'consultations.manage');
return view('care.queue.index', [
'organization' => $organization,
'branches' => $branches,
'branchId' => $branchId,
'practitioners' => Practitioner::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get(),
'practitionerId' => $practitionerId,
'queue' => $queue,
'inConsultation' => $inConsultation,
'canManageQueue' => $canManageQueue,
'canConsult' => $canConsult,
]);
}
public function start(Request $request, Appointment $appointment): RedirectResponse
{
$this->authorizeAbility($request, 'consultations.manage');
$this->authorizeAppointment($request, $appointment);
$practitionerId = $request->input('practitioner_id')
? (int) $request->input('practitioner_id')
: $appointment->practitioner_id;
$this->appointments->startConsultation(
$appointment,
$this->ownerRef($request),
$practitionerId,
$this->ownerRef($request),
);
$consultation = $this->consultations->startFromAppointment(
$appointment->fresh(),
$this->ownerRef($request),
$this->ownerRef($request),
);
return redirect()->route('care.consultations.show', $consultation)
->with('success', 'Consultation started.');
}
protected function authorizeAppointment(Request $request, Appointment $appointment): void
{
$this->authorizeOwner($request, $appointment);
abort_unless($appointment->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $appointment->branch_id !== $branchId) {
abort(404);
}
}
}
@@ -0,0 +1,134 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\ReportService;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ReportController extends Controller
{
use ScopesToAccount;
public function __construct(
protected ReportService $reports,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'reports.finance.view');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('care.reports.index', [
'organization' => $organization,
'branches' => $branches,
'reports' => config('care.report_types'),
]);
}
public function show(Request $request, string $type): View
{
$this->authorizeReport($request, $type);
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope;
[$from, $to] = $this->dateRange($request);
$data = match ($type) {
'patients' => $this->reports->patientsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
'appointments' => $this->reports->appointmentsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
'laboratory' => $this->reports->laboratoryReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
'finance' => $this->reports->financeReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
'clinical' => ['diagnoses' => $this->reports->clinicalReport($this->ownerRef($request), $organization->id, $from, $to)],
default => abort(404),
};
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->orderBy('name')
->get();
return view('care.reports.show', [
'type' => $type,
'label' => config('care.report_types')[$type] ?? $type,
'data' => $data,
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'branchId' => $branchId,
'branches' => $branches,
'canExport' => app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'reports.finance.export'),
]);
}
public function export(Request $request, string $type): StreamedResponse
{
$this->authorizeReport($request, $type);
abort_unless(
app(\App\Services\Care\CarePermissions::class)->can($this->member($request), 'reports.finance.export'),
403,
);
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope;
[$from, $to] = $this->dateRange($request);
$data = match ($type) {
'patients' => $this->reports->patientsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
'appointments' => $this->reports->appointmentsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
'laboratory' => $this->reports->laboratoryReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
'finance' => $this->reports->financeReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
'clinical' => ['diagnoses' => $this->reports->clinicalReport($this->ownerRef($request), $organization->id, $from, $to)],
default => abort(404),
};
$filename = "care-report-{$type}-".now()->format('Y-m-d').'.csv';
return response()->streamDownload(function () use ($data, $type) {
$handle = fopen('php://output', 'w');
if ($type === 'clinical' && isset($data['diagnoses'])) {
fputcsv($handle, ['Diagnosis', 'Count']);
foreach ($data['diagnoses'] as $row) {
fputcsv($handle, [$row->description, $row->total]);
}
} else {
fputcsv($handle, ['Metric', 'Value']);
foreach ($data as $key => $value) {
fputcsv($handle, [$key, is_scalar($value) ? $value : json_encode($value)]);
}
}
fclose($handle);
}, $filename, ['Content-Type' => 'text/csv']);
}
protected function authorizeReport(Request $request, string $type): void
{
abort_unless(array_key_exists($type, config('care.report_types')), 404);
$this->authorizeAbility($request, 'reports.finance.view');
}
/**
* @return array{0: Carbon, 1: Carbon}
*/
protected function dateRange(Request $request): array
{
$from = Carbon::parse($request->input('from', now()->subDays(30)->toDateString()))->startOfDay();
$to = Carbon::parse($request->input('to', now()->toDateString()))->endOfDay();
return [$from, $to];
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Services\Care\AuditLogger;
use App\Services\Care\CarePermissions;
use App\Support\OrganizationBranding;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SettingsController extends Controller
{
use ScopesToAccount;
public function edit(Request $request): View
{
$this->authorizeAbility($request, 'settings.view');
$organization = $this->organization($request);
$canManage = app(CarePermissions::class)->can($this->member($request), 'settings.manage');
$branchCount = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->count();
return view('care.settings.edit', [
'organization' => $organization,
'canManage' => $canManage,
'branchCount' => $branchCount,
'facilityTypes' => [
'clinic' => 'Clinic',
'hospital' => 'Hospital',
'diagnostic' => 'Diagnostic laboratory',
'specialist' => 'Specialist practice',
],
]);
}
public function update(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'timezone' => ['required', 'timezone'],
'facility_type' => ['required', 'string', 'in:clinic,hospital,diagnostic,specialist'],
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
'remove_logo' => ['nullable', 'boolean'],
]);
$settings = $organization->settings ?? [];
$settings['onboarded'] = true;
$settings['facility_type'] = $validated['facility_type'];
$logoPath = $organization->logo_path;
if ($request->boolean('remove_logo')) {
OrganizationBranding::deleteStoredLogo($organization);
$logoPath = null;
}
if ($request->hasFile('logo')) {
$logoPath = OrganizationBranding::storeLogo($organization, $request->file('logo'));
}
$organization->update([
'name' => $validated['name'],
'timezone' => $validated['timezone'],
'logo_path' => $logoPath,
'settings' => $settings,
]);
AuditLogger::record($owner, 'organization.updated', $organization->id, $owner, \App\Models\Organization::class, $organization->id);
return back()->with('success', 'Settings saved.');
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}