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
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4
+37
View File
@@ -0,0 +1,37 @@
APP_NAME="Ladill Care"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=https://care.ladill.com
PLATFORM_URL=https://ladill.com
PLATFORM_DOMAIN=ladill.com
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=sqlite
DB_DATABASE=database/database.sqlite
# Production: DB_CONNECTION=mysql, DB_HOST=127.0.0.1, DB_DATABASE=ladill_care, DB_USERNAME=ladill_care
SESSION_DRIVER=database
SESSION_LIFETIME=1440
CACHE_STORE=database
QUEUE_CONNECTION=database
# --- Ladill SSO (Sign in with Ladill — auth.ladill.com OIDC client) ---
LADILL_SSO_CLIENT_ID=
LADILL_SSO_CLIENT_SECRET=
# --- Platform APIs this app consumes (per-consumer service keys) ---
BILLING_API_URL=https://ladill.com/api/billing
BILLING_API_KEY_CARE=
IDENTITY_API_URL=https://ladill.com/api
IDENTITY_API_KEY_CARE=
# Platform events webhook (user/org lifecycle from the monolith)
SERVICE_EVENTS_INBOUND_SECRET=
# --- Inbound service API keys (sibling Ladill apps calling Care) ---
CARE_API_KEY_FRONTDESK=
CARE_API_KEY_CRM=
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+110
View File
@@ -0,0 +1,110 @@
name: Deploy Ladill Care
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-care
cancel-in-progress: true
jobs:
deploy:
runs-on: deploy
env:
NODE_ROOT: /tmp/ladill-node-22-r1
NODE_VERSION: "22.14.0"
RELEASE_ARCHIVE: /tmp/ladill-care-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-care-${{ gitea.run_id }}-${{ gitea.run_attempt }}
LADILL_APP_ROOT: /var/www/ladill-care
steps:
- name: Checkout
shell: bash {0}
run: |
set -Eeuo pipefail
DEPLOY_GITEA_TOKEN="$(cat /home/deploy/.ladill-deploy-gitea-token)"
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
rm -rf "$WORKSPACE"
mkdir -p "$WORKSPACE"
export GIT_TERMINAL_PROMPT=0
git \
-c credential.helper= \
-c http.extraHeader="Authorization: token ${DEPLOY_GITEA_TOKEN}" \
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
"$REPO_URL" "$WORKSPACE"
- name: Setup Node.js
shell: bash {0}
run: |
set -Eeuo pipefail
if [ ! -x "$NODE_ROOT/bin/node" ]; then
rm -rf "$NODE_ROOT"
mkdir -p "$NODE_ROOT"
case "$(uname -m)" in
x86_64|amd64) NODE_ARCH="x64" ;;
aarch64|arm64) NODE_ARCH="arm64" ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar -xJ --strip-components=1 -C "$NODE_ROOT"
fi
"$NODE_ROOT/bin/node" -v
- name: Build frontend assets
shell: bash {0}
run: |
set -Eeuo pipefail
cd "$WORKSPACE"
export PATH="$NODE_ROOT/bin:$PATH"
npm ci --no-audit --no-fund
npm run build
- name: Build release archive
shell: bash {0}
run: |
set -Eeuo pipefail
cd "$WORKSPACE"
printf '%s\n' "${{ gitea.sha }}" > REVISION
rm -f "$RELEASE_ARCHIVE"
tar -czf "$RELEASE_ARCHIVE" \
--exclude=.git --exclude=.gitea --exclude=.github --exclude=.env \
--exclude=node_modules --exclude=vendor --exclude=storage --exclude=tests .
- name: Deploy release
shell: bash {0}
env:
LADILL_RELEASE_ARCHIVE: /tmp/ladill-care-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
run: |
set -Eeuo pipefail
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
bash "$WORKSPACE/deploy/deploy.sh"
- name: Ensure nginx vhost
shell: bash {0}
run: |
set -Eeuo pipefail
NGINX_SCRIPT="$WORKSPACE/deployment/setup-service-subdomain-nginx.sh"
if [ ! -f "$NGINX_SCRIPT" ]; then
NGINX_SCRIPT="/var/www/ladill.com/current/deployment/setup-service-subdomain-nginx.sh"
fi
if [ ! -f "$NGINX_SCRIPT" ]; then
echo "WARN: setup-service-subdomain-nginx.sh not found — configure care.ladill.com vhost manually"
exit 0
fi
if sudo -n bash "$NGINX_SCRIPT" care --app /var/www/ladill-care/current; then
echo "nginx vhost updated for care.ladill.com"
else
echo "WARN: nginx vhost step skipped (deploy user cannot sudo) — vhost must already be provisioned"
fi
- name: Cleanup
if: always()
shell: bash {0}
run: |
rm -rf "$WORKSPACE"
rm -f "$RELEASE_ARCHIVE"
+31
View File
@@ -0,0 +1,31 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/storage/framework/views/*
!/storage/framework/views/.gitignore
/vendor
Homestead.json
Homestead.yaml
Thumbs.db
# Native mobile apps — kept locally, not tracked in this repo
/apps
/database/database.sqlite
+85
View File
@@ -0,0 +1,85 @@
# Ladill Care — deploy runbook
Healthcare management at **care.ladill.com** (patients, appointments, consultations, lab, pharmacy, billing).
## 1. Gitea repo + CI
- Repo: **ladill-care** (`isaacclad/ladill-care`).
- Push to `main` triggers `.gitea/workflows/deploy.yml` (on-host `deploy` runner).
- App root: `/var/www/ladill-care`.
To publish from the monorepo copy:
```bash
bash scripts/extract-ladill-care.sh
```
## 2. Server app-slot + database
```bash
sudo install -d -o deploy -g www-data /var/www/ladill-care
sudo install -d -o deploy -g www-data /var/www/ladill-care/{releases,shared}
sudo mysql -e "CREATE DATABASE ladill_care CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
sudo mysql -e "CREATE USER 'ladill_care'@'127.0.0.1' IDENTIFIED BY '<pw>';"
sudo mysql -e "GRANT ALL ON ladill_care.* TO 'ladill_care'@'127.0.0.1'; FLUSH PRIVILEGES;"
```
## 3. Platform wiring (monolith)
On the Ladill platform host:
```bash
cd /var/www/ladill.com/current
php artisan ladill:onboard-app care --run-dns
php artisan passport:client --name="Ladill Care" --redirect_uri=https://care.ladill.com/sso/callback
php artisan ladill:launcher:sync --propagate
```
Apply the generated `IDENTITY_API_KEY_CARE`, `BILLING_API_KEY_CARE`,
`SERVICE_EVENTS_CARE_*`, and `SERVICE_EVENTS_INBOUND_SECRET` values to the monolith `.env`, then:
```bash
php artisan config:cache
```
## 4. Shared `.env` (`/var/www/ladill-care/shared/.env`)
Copy `.env.example`, set production values:
| Variable | Notes |
|----------|-------|
| `APP_KEY` | `php artisan key:generate --show` |
| `APP_URL` | `https://care.ladill.com` |
| `DB_*` | MySQL `ladill_care` credentials |
| `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | Passport client (plain text from `passport:client`) |
| `BILLING_API_KEY_CARE` | Same as monolith consumer key |
| `IDENTITY_API_KEY_CARE` | Same as monolith consumer key |
| `SERVICE_EVENTS_INBOUND_SECRET` | Same as monolith `SERVICE_EVENTS_CARE_SECRET` |
**SSO secret:** use the plain-text value printed once by `passport:client` — never copy
`oauth_clients.secret` from the database (Passport stores a bcrypt hash there).
## 5. nginx + TLS
```bash
sudo bash deployment/setup-service-subdomain-nginx.sh care --app /var/www/ladill-care/current
```
DNS: `care.ladill.com` → app server (via `dns:sync-subdomains` on the monolith).
## 6. First deploy
Push to `main` or run the Gitea deploy workflow manually. Then verify:
```bash
curl -sI https://care.ladill.com | head -1
curl -s -o /dev/null -w '%{http_code}\n' https://care.ladill.com/login
curl -s https://care.ladill.com/api/health
```
## 7. Optional queue worker
```bash
sudo cp deployment/supervisor/ladill-care-worker.conf /etc/supervisor/conf.d/
sudo supervisorctl reread && sudo supervisorctl update
```
+55
View File
@@ -0,0 +1,55 @@
# Ladill Care
Healthcare management for clinics and hospitals at **care.ladill.com** — patients,
appointments, queue, consultations, vitals, lab, prescriptions, pharmacy inventory,
encounter billing, and operational reports.
Authenticates via **Sign in with Ladill** (OIDC against `auth.ladill.com`). Every
clinical record is scoped to a platform account (`owner_ref`) and organization.
## Features
- **Patients** — registration, medical history, documents, visit timeline
- **Appointments & queue** — booking, walk-in, check-in, department queue
- **Consultations** — vitals, diagnoses, investigations, prescriptions
- **Laboratory** — test catalog, sample collection, results, approval
- **Pharmacy** — dispensing queue, drug inventory, batch stock tracking
- **Billing** — visit invoices, line items, payments, print view
- **Reports** — patients, appointments, lab, finance, clinical (CSV export)
- **Admin** — branches, departments, practitioners, members, audit log
## Platform integration
| Integration | Purpose |
|-------------|---------|
| OIDC SSO | User authentication (`LADILL_SSO_*`) |
| Billing API | Wallet top-up / purchases (`BILLING_API_KEY_CARE`) |
| Identity API | Platform user lookups (`IDENTITY_API_KEY_CARE`) |
| Service events | Inbound webhooks for `user.deleted`, `user.suspended`, `organization.updated` |
Registry entry lives in the monolith `config/ladill_apps.php` (`care` slug).
## Local development
```bash
composer install
cp .env.example .env && php artisan key:generate
touch database/database.sqlite # sqlite is fine for local
php artisan migrate
npm install && npm run build # or `npm run dev`
php artisan serve
php artisan test
```
For SSO locally, point `LADILL_SSO_*` at a Passport client on your Ladill platform
dev instance, or bypass `EnsurePlatformSession` in tests (see `CareWebTest`).
## Deployment
See [DEPLOY.md](DEPLOY.md) for production cutover (`/var/www/ladill-care`, Gitea CI,
nginx vhost, platform onboarding).
## API
REST API under `/api/v1` (Sanctum + organization setup middleware). Health check:
`GET /api/health`.
@@ -0,0 +1,13 @@
<?php
namespace App\Contracts\Printers;
use App\Models\Visit;
interface PrinterDriverInterface
{
public function name(): string;
/** @return array{format: string, content: string, filename?: string} */
public function renderBadge(Visit $visit): array;
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* A platform domain event received via the inbound service-events webhook.
*/
class ServiceEventOccurred
{
use Dispatchable, SerializesModels;
/**
* @param array<string, mixed> $data
*/
public function __construct(
public readonly string $name,
public readonly array $data = [],
) {}
}
@@ -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;
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Generic service-to-service auth for internal APIs. Validates the bearer token
* against per-consumer keys in config("{namespace}.service_api_keys") with a
* constant-time compare, and records the caller. Usage: auth.service:crm.
*/
class AuthenticateService
{
public function handle(Request $request, Closure $next, string $namespace = 'crm'): Response
{
$token = (string) $request->bearerToken();
$caller = null;
if ($token !== '') {
foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) {
if (is_string($key) && $key !== '' && hash_equals($key, $token)) {
$caller = $name;
break;
}
}
}
if ($caller === null) {
return response()->json(['error' => 'Unauthorized.'], 401);
}
$request->attributes->set('service_caller', $caller);
return $next($request);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use App\Services\Care\CarePermissions;
use App\Services\Care\OrganizationResolver;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureCareAbility
{
public function __construct(
protected OrganizationResolver $organizations,
protected CarePermissions $permissions,
) {}
public function handle(Request $request, Closure $next, string $ability): Response
{
$user = $request->user();
abort_unless($user, 403);
$organization = $this->organizations->resolveForUser($user);
abort_unless($organization, 403);
$member = $this->organizations->memberFor($user, $organization);
abort_unless($this->permissions->can($member, $ability), 403);
$request->attributes->set('care.organization', $organization);
$request->attributes->set('care.member', $member);
return $next($request);
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use App\Services\Care\OrganizationResolver;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureOrganizationSetup
{
public function __construct(
protected OrganizationResolver $organizations,
) {}
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
if ($request->routeIs('care.onboarding*')) {
return $next($request);
}
if (! $this->organizations->isOnboarded($user)) {
return redirect()->route('care.onboarding.show');
}
return $next($request);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
/**
* App sessions are subordinate to the platform (auth.ladill.com) session.
* When the platform session ends, clear this app's local session too.
*/
class EnsurePlatformSession
{
public function handle(Request $request, Closure $next): Response
{
if (! Auth::check()) {
return $next($request);
}
$authDomain = trim((string) config('app.auth_domain', ''));
if ($authDomain === '') {
return $next($request);
}
$cookieHeader = (string) $request->headers->get('Cookie', '');
if ($cookieHeader === '') {
return $this->clearAppSession($request);
}
try {
$response = Http::withHeaders(['Cookie' => $cookieHeader])
->timeout(3)
->get('https://'.$authDomain.'/sso/ping');
} catch (\Throwable) {
return $next($request);
}
if ($response->status() === 401) {
return $this->clearAppSession($request);
}
return $next($request);
}
private function clearAppSession(Request $request): Response
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.connect', [
'redirect' => $request->fullUrl(),
]);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
/**
* Shares the signed-in account with views. CRM data is scoped per account by
* the user's public_id (owner_ref); today every user acts as their own
* account (team support graduates here later, like the platform pattern).
*/
class SetActingAccount
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->user()) {
$request->attributes->set('actingAccount', $user);
if (! $request->is('api/*')) {
View::share('actingAccount', $user);
}
}
return $next($request);
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Listeners;
use App\Events\ServiceEventOccurred;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Reacts to platform lifecycle events pushed from the Ladill monolith.
*/
class PlatformServiceEventListener
{
public function handle(ServiceEventOccurred $event): void
{
match ($event->name) {
'user.deleted' => $this->handleUserDeleted($event),
'user.suspended' => $this->handleUserSuspended($event),
'organization.updated' => $this->handleOrganizationUpdated($event),
default => null,
};
}
protected function handleUserDeleted(ServiceEventOccurred $event): void
{
$publicId = (string) ($event->data['user'] ?? '');
if ($publicId === '') {
return;
}
DB::transaction(function () use ($publicId) {
Member::where('user_ref', $publicId)->delete();
User::where('public_id', $publicId)->delete();
});
Log::info('PlatformServiceEventListener: user deleted', ['public_id' => $publicId]);
}
protected function handleUserSuspended(ServiceEventOccurred $event): void
{
$publicId = (string) ($event->data['user'] ?? '');
if ($publicId === '') {
return;
}
$user = User::where('public_id', $publicId)->first();
if ($user) {
$user->tokens()->delete();
}
Log::info('PlatformServiceEventListener: user suspended', ['public_id' => $publicId]);
}
protected function handleOrganizationUpdated(ServiceEventOccurred $event): void
{
$ownerRef = (string) ($event->data['owner'] ?? $event->data['user'] ?? '');
if ($ownerRef === '') {
return;
}
$organization = Organization::owned($ownerRef)->first();
if (! $organization) {
return;
}
$updates = array_filter([
'name' => $event->data['name'] ?? null,
'timezone' => $event->data['timezone'] ?? null,
], fn ($value) => $value !== null && $value !== '');
if ($updates === []) {
return;
}
$organization->update($updates);
Log::info('PlatformServiceEventListener: organization updated', [
'organization_id' => $organization->id,
'owner_ref' => $ownerRef,
]);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ContactMessageMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public string $subjectLine,
public string $bodyText,
public ?string $fromName = null,
public ?string $replyToAddress = null,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->subjectLine,
replyTo: $this->replyToAddress ? [$this->replyToAddress] : [],
);
}
public function content(): Content
{
return new Content(
view: 'email.contact-message',
with: [
'bodyText' => $this->bodyText,
'fromName' => $this->fromName,
],
);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Appointment extends Model
{
use BelongsToOwner, SoftDeletes;
public const STATUS_SCHEDULED = 'scheduled';
public const STATUS_CHECKED_IN = 'checked_in';
public const STATUS_WAITING = 'waiting';
public const STATUS_IN_CONSULTATION = 'in_consultation';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_NO_SHOW = 'no_show';
public const TYPE_SCHEDULED = 'scheduled';
public const TYPE_WALK_IN = 'walk_in';
protected $table = 'care_appointments';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'patient_id',
'practitioner_id', 'department_id', 'visit_id', 'type', 'status',
'scheduled_at', 'checked_in_at', 'waiting_at', 'started_at',
'completed_at', 'cancelled_at', 'queue_position', 'reason', 'notes', 'created_by',
];
protected function casts(): array
{
return [
'scheduled_at' => 'datetime',
'checked_in_at' => 'datetime',
'waiting_at' => 'datetime',
'started_at' => 'datetime',
'completed_at' => 'datetime',
'cancelled_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (Appointment $appointment) {
if (! $appointment->uuid) {
$appointment->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
public function practitioner(): BelongsTo
{
return $this->belongsTo(Practitioner::class, 'practitioner_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class, 'department_id');
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function consultation(): HasOne
{
return $this->hasOne(Consultation::class, 'appointment_id');
}
/** @return list<string> */
public static function activeStatuses(): array
{
return [
self::STATUS_SCHEDULED,
self::STATUS_CHECKED_IN,
self::STATUS_WAITING,
self::STATUS_IN_CONSULTATION,
];
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AuditLog extends Model
{
use BelongsToOwner;
protected $table = 'care_audit_logs';
public $timestamps = false;
protected $fillable = [
'owner_ref', 'organization_id', 'actor_ref', 'action',
'subject_type', 'subject_id', 'metadata', 'ip_address', 'created_at',
];
protected function casts(): array
{
return [
'metadata' => 'array',
'created_at' => 'datetime',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public static function record(
string $ownerRef,
string $action,
?int $organizationId = null,
?string $actorRef = null,
?string $subjectType = null,
?int $subjectId = null,
?array $metadata = null,
): self {
return static::create([
'owner_ref' => $ownerRef,
'organization_id' => $organizationId,
'actor_ref' => $actorRef,
'action' => $action,
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'metadata' => $metadata,
'ip_address' => request()?->ip(),
'created_at' => now(),
]);
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Bill extends Model
{
use BelongsToOwner, SoftDeletes;
public const STATUS_DRAFT = 'draft';
public const STATUS_OPEN = 'open';
public const STATUS_PARTIAL = 'partial';
public const STATUS_PAID = 'paid';
public const STATUS_VOID = 'void';
protected $table = 'care_bills';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'visit_id', 'patient_id',
'invoice_number', 'status', 'subtotal_minor', 'discount_minor', 'tax_minor',
'total_minor', 'amount_paid_minor', 'balance_minor', 'notes', 'created_by', 'finalized_at',
];
protected function casts(): array
{
return ['finalized_at' => 'datetime'];
}
protected static function booted(): void
{
static::creating(function (Bill $bill) {
if (! $bill->uuid) {
$bill->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function lineItems(): HasMany
{
return $this->hasMany(BillLineItem::class, 'bill_id');
}
public function payments(): HasMany
{
return $this->hasMany(Payment::class, 'bill_id');
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BillLineItem extends Model
{
use BelongsToOwner;
protected $table = 'care_bill_line_items';
protected $fillable = [
'owner_ref', 'bill_id', 'type', 'description', 'quantity',
'unit_price_minor', 'total_minor', 'source_type', 'source_id',
];
public function bill(): BelongsTo
{
return $this->belongsTo(Bill::class, 'bill_id');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Branch extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'care_branches';
protected $fillable = [
'owner_ref', 'organization_id', 'name', 'code', 'address', 'phone', 'is_active',
];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function departments(): HasMany
{
return $this->hasMany(Department::class, 'branch_id');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait BelongsToOwner
{
public function scopeOwned(Builder $query, string $ownerRef): Builder
{
return $query->where($this->getTable().'.owner_ref', $ownerRef);
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class Consultation extends Model
{
use BelongsToOwner, SoftDeletes;
public const STATUS_DRAFT = 'draft';
public const STATUS_COMPLETED = 'completed';
protected $table = 'care_consultations';
protected $fillable = [
'uuid', 'owner_ref', 'visit_id', 'appointment_id', 'practitioner_id',
'patient_id', 'status', 'symptoms', 'clinical_notes',
'started_at', 'completed_at', 'completed_by',
];
protected function casts(): array
{
return [
'started_at' => 'datetime',
'completed_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (Consultation $consultation) {
if (! $consultation->uuid) {
$consultation->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function appointment(): BelongsTo
{
return $this->belongsTo(Appointment::class, 'appointment_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
public function practitioner(): BelongsTo
{
return $this->belongsTo(Practitioner::class, 'practitioner_id');
}
public function vitalSigns(): HasMany
{
return $this->hasMany(VitalSign::class, 'consultation_id');
}
public function diagnoses(): HasMany
{
return $this->hasMany(Diagnosis::class, 'consultation_id');
}
public function documents(): HasMany
{
return $this->hasMany(ConsultationDocument::class, 'consultation_id');
}
public function investigationRequests(): HasMany
{
return $this->hasMany(InvestigationRequest::class, 'consultation_id');
}
public function prescriptions(): HasMany
{
return $this->hasMany(Prescription::class, 'consultation_id');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class ConsultationDocument extends Model
{
use BelongsToOwner;
protected $table = 'care_consultation_documents';
protected $fillable = [
'owner_ref', 'consultation_id', 'file_path', 'original_name',
'mime_type', 'uploaded_by',
];
public function consultation(): BelongsTo
{
return $this->belongsTo(Consultation::class, 'consultation_id');
}
public function url(): ?string
{
return $this->file_path && Storage::disk('public')->exists($this->file_path)
? Storage::disk('public')->url($this->file_path)
: null;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Department extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'care_departments';
protected $fillable = [
'owner_ref', 'branch_id', 'name', 'type', 'is_active',
];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Diagnosis extends Model
{
use BelongsToOwner;
protected $table = 'care_diagnoses';
protected $fillable = [
'owner_ref', 'consultation_id', 'code', 'description', 'is_primary', 'notes',
];
protected function casts(): array
{
return ['is_primary' => 'boolean'];
}
public function consultation(): BelongsTo
{
return $this->belongsTo(Consultation::class, 'consultation_id');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DispensingRecord extends Model
{
use BelongsToOwner;
protected $table = 'care_dispensing_records';
protected $fillable = [
'owner_ref', 'prescription_id', 'prescription_item_id',
'drug_batch_id', 'quantity', 'dispensed_by', 'dispensed_at',
];
protected function casts(): array
{
return ['dispensed_at' => 'datetime'];
}
public function prescription(): BelongsTo
{
return $this->belongsTo(Prescription::class, 'prescription_id');
}
public function prescriptionItem(): BelongsTo
{
return $this->belongsTo(PrescriptionItem::class, 'prescription_item_id');
}
public function drugBatch(): BelongsTo
{
return $this->belongsTo(DrugBatch::class, 'drug_batch_id');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Drug extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'care_drugs';
protected $fillable = [
'owner_ref', 'organization_id', 'branch_id', 'name', 'generic_name',
'sku', 'unit', 'unit_price_minor', 'reorder_level', 'is_active',
];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function batches(): HasMany
{
return $this->hasMany(DrugBatch::class, 'drug_id');
}
public function stockOnHand(): int
{
return (int) $this->batches()->sum('quantity_on_hand');
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class DrugBatch extends Model
{
use BelongsToOwner;
protected $table = 'care_drug_batches';
protected $fillable = [
'owner_ref', 'drug_id', 'batch_number', 'expiry_date',
'quantity_on_hand', 'cost_minor', 'received_at',
];
protected function casts(): array
{
return [
'expiry_date' => 'date',
'received_at' => 'datetime',
];
}
public function drug(): BelongsTo
{
return $this->belongsTo(Drug::class, 'drug_id');
}
public function dispensingRecords(): HasMany
{
return $this->hasMany(DispensingRecord::class, 'drug_batch_id');
}
public function isExpired(): bool
{
return $this->expiry_date !== null && $this->expiry_date->isPast();
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EmergencyContact extends Model
{
use BelongsToOwner;
protected $table = 'care_emergency_contacts';
protected $fillable = ['owner_ref', 'patient_id', 'name', 'phone', 'relationship', 'is_primary'];
protected function casts(): array
{
return ['is_primary' => 'boolean'];
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InsurancePolicy extends Model
{
use BelongsToOwner;
protected $table = 'care_insurance_policies';
protected $fillable = [
'owner_ref', 'patient_id', 'provider_name', 'policy_number',
'coverage_type', 'expiry_date', 'notes',
];
protected function casts(): array
{
return ['expiry_date' => 'date'];
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InvestigationAttachment extends Model
{
use BelongsToOwner;
protected $table = 'care_investigation_attachments';
protected $fillable = [
'owner_ref', 'investigation_result_id', 'file_path', 'original_name',
'mime_type', 'uploaded_by',
];
public function result(): BelongsTo
{
return $this->belongsTo(InvestigationResult::class, 'investigation_result_id');
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class InvestigationRequest extends Model
{
use BelongsToOwner, SoftDeletes;
public const STATUS_PENDING = 'pending';
public const STATUS_SAMPLE_COLLECTED = 'sample_collected';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_AWAITING_REVIEW = 'awaiting_review';
public const STATUS_COMPLETED = 'completed';
public const STATUS_DELIVERED = 'delivered';
public const STATUS_CANCELLED = 'cancelled';
protected $table = 'care_investigation_requests';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'visit_id', 'consultation_id',
'patient_id', 'investigation_type_id', 'practitioner_id', 'status', 'priority',
'clinical_notes', 'requested_by', 'assigned_member_id', 'sample_barcode',
'sample_collected_at', 'sample_collected_by', 'completed_at', 'delivered_at',
'approved_by', 'approved_at',
];
protected function casts(): array
{
return [
'sample_collected_at' => 'datetime',
'completed_at' => 'datetime',
'delivered_at' => 'datetime',
'approved_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (InvestigationRequest $request) {
if (! $request->uuid) {
$request->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function investigationType(): BelongsTo
{
return $this->belongsTo(InvestigationType::class, 'investigation_type_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function consultation(): BelongsTo
{
return $this->belongsTo(Consultation::class, 'consultation_id');
}
public function practitioner(): BelongsTo
{
return $this->belongsTo(Practitioner::class, 'practitioner_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function assignedMember(): BelongsTo
{
return $this->belongsTo(Member::class, 'assigned_member_id');
}
public function result(): HasOne
{
return $this->hasOne(InvestigationResult::class, 'investigation_request_id');
}
/** @return list<string> */
public static function activeStatuses(): array
{
return [
self::STATUS_PENDING,
self::STATUS_SAMPLE_COLLECTED,
self::STATUS_IN_PROGRESS,
self::STATUS_AWAITING_REVIEW,
self::STATUS_COMPLETED,
];
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class InvestigationResult extends Model
{
use BelongsToOwner;
public const STATUS_DRAFT = 'draft';
public const STATUS_APPROVED = 'approved';
protected $table = 'care_investigation_results';
protected $fillable = [
'owner_ref', 'investigation_request_id', 'result_summary', 'interpretation',
'is_abnormal', 'status', 'entered_by', 'approved_by', 'approved_at',
];
protected function casts(): array
{
return [
'is_abnormal' => 'boolean',
'approved_at' => 'datetime',
];
}
public function request(): BelongsTo
{
return $this->belongsTo(InvestigationRequest::class, 'investigation_request_id');
}
public function values(): HasMany
{
return $this->hasMany(InvestigationResultValue::class, 'investigation_result_id');
}
public function attachments(): HasMany
{
return $this->hasMany(InvestigationAttachment::class, 'investigation_result_id');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InvestigationResultValue extends Model
{
use BelongsToOwner;
protected $table = 'care_investigation_result_values';
protected $fillable = [
'owner_ref', 'investigation_result_id', 'parameter', 'value', 'unit',
'reference_low', 'reference_high', 'reference_text', 'is_abnormal',
];
protected function casts(): array
{
return [
'reference_low' => 'decimal:4',
'reference_high' => 'decimal:4',
'is_abnormal' => 'boolean',
];
}
public function result(): BelongsTo
{
return $this->belongsTo(InvestigationResult::class, 'investigation_result_id');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class InvestigationType extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'care_investigation_types';
protected $fillable = [
'owner_ref', 'organization_id', 'name', 'code', 'category', 'description',
'unit', 'reference_low', 'reference_high', 'reference_text', 'price_minor', 'is_active',
];
protected function casts(): array
{
return [
'reference_low' => 'decimal:4',
'reference_high' => 'decimal:4',
'is_active' => 'boolean',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function requests(): HasMany
{
return $this->hasMany(InvestigationRequest::class, 'investigation_type_id');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Member extends Model
{
use BelongsToOwner;
protected $table = 'care_members';
protected $fillable = ['owner_ref', 'organization_id', 'user_ref', 'role', 'branch_id'];
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function hasRole(string ...$roles): bool
{
return in_array($this->role, $roles, true);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Organization extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'care_organizations';
protected $fillable = [
'owner_ref', 'name', 'slug', 'logo_path', 'timezone', 'settings',
];
protected function casts(): array
{
return ['settings' => 'array'];
}
public function branches(): HasMany
{
return $this->hasMany(Branch::class, 'organization_id');
}
public function members(): HasMany
{
return $this->hasMany(Member::class, 'organization_id');
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Patient extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'care_patients';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'patient_number',
'first_name', 'last_name', 'other_names', 'gender', 'date_of_birth',
'phone', 'email', 'national_id', 'address', 'city', 'region', 'notes',
];
protected function casts(): array
{
return ['date_of_birth' => 'date'];
}
protected static function booted(): void
{
static::creating(function (Patient $patient) {
if (! $patient->uuid) {
$patient->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function fullName(): string
{
return trim(implode(' ', array_filter([
$this->first_name,
$this->other_names,
$this->last_name,
])));
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function allergies(): HasMany
{
return $this->hasMany(PatientAllergy::class, 'patient_id');
}
public function conditions(): HasMany
{
return $this->hasMany(PatientCondition::class, 'patient_id');
}
public function familyHistory(): HasMany
{
return $this->hasMany(PatientFamilyHistory::class, 'patient_id');
}
public function emergencyContacts(): HasMany
{
return $this->hasMany(EmergencyContact::class, 'patient_id');
}
public function insurancePolicies(): HasMany
{
return $this->hasMany(InsurancePolicy::class, 'patient_id');
}
public function attachments(): HasMany
{
return $this->hasMany(PatientAttachment::class, 'patient_id');
}
public function appointments(): HasMany
{
return $this->hasMany(Appointment::class, 'patient_id');
}
public function visits(): HasMany
{
return $this->hasMany(Visit::class, 'patient_id');
}
public function investigationRequests(): HasMany
{
return $this->hasMany(InvestigationRequest::class, 'patient_id');
}
public function prescriptions(): HasMany
{
return $this->hasMany(Prescription::class, 'patient_id');
}
public function bills(): HasMany
{
return $this->hasMany(Bill::class, 'patient_id');
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PatientAllergy extends Model
{
use BelongsToOwner;
protected $table = 'care_patient_allergies';
protected $fillable = ['owner_ref', 'patient_id', 'allergen', 'severity', 'notes'];
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class PatientAttachment extends Model
{
use BelongsToOwner;
protected $table = 'care_patient_attachments';
protected $fillable = [
'owner_ref', 'patient_id', 'file_path', 'original_name',
'mime_type', 'document_type', 'uploaded_by',
];
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
public function url(): ?string
{
return $this->file_path && Storage::disk('public')->exists($this->file_path)
? Storage::disk('public')->url($this->file_path)
: null;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PatientCondition extends Model
{
use BelongsToOwner;
protected $table = 'care_patient_conditions';
protected $fillable = ['owner_ref', 'patient_id', 'condition', 'onset_date', 'is_chronic', 'notes'];
protected function casts(): array
{
return [
'onset_date' => 'date',
'is_chronic' => 'boolean',
];
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PatientFamilyHistory extends Model
{
use BelongsToOwner;
protected $table = 'care_patient_family_history';
protected $fillable = ['owner_ref', 'patient_id', 'relation', 'condition', 'notes'];
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class Payment extends Model
{
use BelongsToOwner;
protected $table = 'care_payments';
protected $fillable = [
'uuid', 'owner_ref', 'bill_id', 'amount_minor', 'method',
'reference', 'paid_at', 'recorded_by', 'notes',
];
protected function casts(): array
{
return ['paid_at' => 'datetime'];
}
protected static function booted(): void
{
static::creating(function (Payment $payment) {
if (! $payment->uuid) {
$payment->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function bill(): BelongsTo
{
return $this->belongsTo(Bill::class, 'bill_id');
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Practitioner extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'care_practitioners';
protected $fillable = [
'owner_ref', 'organization_id', 'branch_id', 'department_id', 'member_id',
'user_ref', 'name', 'specialty', 'is_active',
];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class, 'department_id');
}
public function member(): BelongsTo
{
return $this->belongsTo(Member::class, 'member_id');
}
public function schedules(): HasMany
{
return $this->hasMany(PractitionerSchedule::class, 'practitioner_id');
}
public function appointments(): HasMany
{
return $this->hasMany(Appointment::class, 'practitioner_id');
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PractitionerSchedule extends Model
{
use BelongsToOwner;
protected $table = 'care_practitioner_schedules';
protected $fillable = ['owner_ref', 'practitioner_id', 'day_of_week', 'start_time', 'end_time'];
public function practitioner(): BelongsTo
{
return $this->belongsTo(Practitioner::class, 'practitioner_id');
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Prescription extends Model
{
use BelongsToOwner, SoftDeletes;
public const STATUS_DRAFT = 'draft';
public const STATUS_ACTIVE = 'active';
public const STATUS_DISPENSED = 'dispensed';
public const STATUS_CANCELLED = 'cancelled';
protected $table = 'care_prescriptions';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'visit_id', 'consultation_id',
'patient_id', 'practitioner_id', 'status', 'notes',
'prescribed_by', 'dispensed_by', 'dispensed_at',
];
protected function casts(): array
{
return ['dispensed_at' => 'datetime'];
}
protected static function booted(): void
{
static::creating(function (Prescription $prescription) {
if (! $prescription->uuid) {
$prescription->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function consultation(): BelongsTo
{
return $this->belongsTo(Consultation::class, 'consultation_id');
}
public function practitioner(): BelongsTo
{
return $this->belongsTo(Practitioner::class, 'practitioner_id');
}
public function items(): HasMany
{
return $this->hasMany(PrescriptionItem::class, 'prescription_id')->orderBy('sort_order');
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PrescriptionItem extends Model
{
use BelongsToOwner;
protected $table = 'care_prescription_items';
protected $fillable = [
'owner_ref', 'prescription_id', 'drug_id', 'is_procedure', 'name', 'dosage',
'frequency', 'duration', 'route', 'quantity', 'instructions', 'sort_order',
];
protected function casts(): array
{
return ['is_procedure' => 'boolean'];
}
public function prescription(): BelongsTo
{
return $this->belongsTo(Prescription::class, 'prescription_id');
}
public function drug(): BelongsTo
{
return $this->belongsTo(Drug::class, 'drug_id');
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
/**
* Thin local mirror of the platform identity (auth.ladill.com owns users).
* Keyed by the OIDC `sub` (public_id); every Care record is scoped to it.
*/
class User extends Authenticatable
{
use HasApiTokens, Notifiable;
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password', 'last_app_active_at'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'last_app_active_at' => 'datetime',
'password' => 'hashed',
];
}
public function ownerRef(): string
{
return (string) $this->public_id;
}
public function avatarUrl(): ?string
{
$url = trim((string) $this->avatar_url);
return $url !== '' ? $url : null;
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Visit extends Model
{
use BelongsToOwner, SoftDeletes;
public const STATUS_OPEN = 'open';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_COMPLETED = 'completed';
protected $table = 'care_visits';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'patient_id',
'status', 'checked_in_at', 'completed_at', 'checked_in_by',
];
protected function casts(): array
{
return [
'checked_in_at' => 'datetime',
'completed_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (Visit $visit) {
if (! $visit->uuid) {
$visit->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function appointment(): HasOne
{
return $this->hasOne(Appointment::class, 'visit_id');
}
public function consultations(): HasMany
{
return $this->hasMany(Consultation::class, 'visit_id');
}
public function bill(): HasOne
{
return $this->hasOne(Bill::class, 'visit_id');
}
public function bills(): HasMany
{
return $this->hasMany(Bill::class, 'visit_id');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class VitalSign extends Model
{
use BelongsToOwner;
protected $table = 'care_vital_signs';
protected $fillable = [
'owner_ref', 'consultation_id', 'bp_systolic', 'bp_diastolic', 'pulse',
'temperature', 'weight_kg', 'height_cm', 'spo2', 'respiratory_rate',
'recorded_by', 'recorded_at',
];
protected function casts(): array
{
return [
'temperature' => 'decimal:1',
'weight_kg' => 'decimal:2',
'height_cm' => 'decimal:1',
'recorded_at' => 'datetime',
];
}
public function consultation(): BelongsTo
{
return $this->belongsTo(Consultation::class, 'consultation_id');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Providers;
use App\Events\ServiceEventOccurred;
use App\Listeners\PlatformServiceEventListener;
use App\Models\User;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PlanService;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Event::listen(ServiceEventOccurred::class, PlatformServiceEventListener::class);
View::composer('partials.sidebar', function ($view) {
/** @var User|null $user */
$user = auth()->user();
$isPro = false;
if ($user) {
$organization = app(OrganizationResolver::class)->resolveForUser($user);
if ($organization) {
$isPro = app(PlanService::class)->isPro($organization);
}
}
$view->with('isPro', $isPro);
});
View::composer(['partials.topbar'], function ($view) {
$view->with(\App\Support\MobileTopbar::resolve());
});
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
/**
* Client for the platform Billing HTTP API the one UserWallet lives on the
* platform; CRM only consumes it. Amounts are integer minor units; the user is
* identified by public_id. Authenticates with the per-consumer config('billing.api_key').
*/
class BillingClient
{
private function base(): string
{
return rtrim((string) config('billing.api_url'), '/');
}
private function token(): string
{
return (string) (config('billing.api_key') ?? '');
}
public function balanceMinor(string $publicId): int
{
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
->get($this->base().'/balance', ['user' => $publicId]);
$res->throw();
return (int) ($res->json('balance_minor') ?? 0);
}
public function canAfford(string $publicId, int $amountMinor): bool
{
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
->get($this->base().'/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor]);
$res->throw();
return (bool) ($res->json('affordable') ?? false);
}
/**
* Debit the wallet. Returns true on success, false on insufficient balance
* (HTTP 402). Idempotent by $reference.
*/
public function debit(string $publicId, int $amountMinor, string $source, string $reference, ?string $description = null): bool
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
'user' => $publicId,
'amount_minor' => $amountMinor,
'service' => (string) config('billing.service', 'crm'),
'source' => $source,
'reference' => $reference,
'description' => $description,
], static fn ($v) => $v !== null));
if ($res->status() === 402) {
return false;
}
$res->throw();
return true;
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Services\Billing;
use App\Models\CrmPurchase;
use Illuminate\Support\Str;
class OneTimePurchaseService
{
public function __construct(private readonly BillingClient $billing)
{
}
/** @return array<string, mixed>|null */
public function product(string $productKey): ?array
{
$product = config('crm_products.'.$productKey);
return is_array($product) ? $product : null;
}
public function hasPurchased(string $ownerRef, string $productKey): bool
{
return CrmPurchase::has($ownerRef, $productKey);
}
public function priceMinor(string $productKey): int
{
return (int) ($this->product($productKey)['price_minor'] ?? 0);
}
public function purchase(string $ownerRef, string $productKey): bool
{
if ($this->hasPurchased($ownerRef, $productKey)) {
return true;
}
$product = $this->product($productKey);
if (! $product) {
return false;
}
$costMinor = (int) ($product['price_minor'] ?? 0);
if ($costMinor > 0) {
try {
if (! $this->billing->canAfford($ownerRef, $costMinor)) {
return false;
}
$charged = $this->billing->debit(
$ownerRef,
$costMinor,
'purchase',
'crm-purchase-'.$productKey.'-'.$ownerRef,
'CRM: '.($product['name'] ?? $productKey),
);
if (! $charged) {
return false;
}
} catch (\Throwable) {
return false;
}
}
CrmPurchase::create([
'owner_ref' => $ownerRef,
'product_key' => $productKey,
'cost_minor' => $costMinor,
'purchased_at' => now(),
]);
return true;
}
}
+283
View File
@@ -0,0 +1,283 @@
<?php
namespace App\Services\Care;
use App\Models\Appointment;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Visit;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use InvalidArgumentException;
class AppointmentService
{
public function __construct(
protected VisitService $visits,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
{
return Appointment::owned($ownerRef)->where('organization_id', $organizationId);
}
/**
* @param array<string, mixed> $filters
*/
public function list(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
{
$query = $this->queryForOrganization($ownerRef, $organizationId)
->with(['patient', 'practitioner', 'branch', 'department'])
->orderByDesc('scheduled_at');
if ($branchId !== null) {
$query->where('branch_id', $branchId);
}
if ($status = $filters['status'] ?? null) {
$query->where('status', $status);
}
if ($practitionerId = $filters['practitioner_id'] ?? null) {
$query->where('practitioner_id', $practitionerId);
}
if ($date = $filters['date'] ?? null) {
$query->whereDate('scheduled_at', Carbon::parse($date)->toDateString());
}
if ($patientId = $filters['patient_id'] ?? null) {
$query->where('patient_id', $patientId);
}
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
}
/**
* @return Collection<int, Appointment>
*/
public function queue(string $ownerRef, int $branchId, ?int $practitionerId = null): Collection
{
$query = Appointment::owned($ownerRef)
->where('branch_id', $branchId)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->with(['patient', 'practitioner'])
->orderBy('queue_position')
->orderBy('waiting_at')
->orderBy('checked_in_at');
if ($practitionerId !== null) {
$query->where(function (Builder $q) use ($practitionerId) {
$q->where('practitioner_id', $practitionerId)->orWhereNull('practitioner_id');
});
}
return $query->get();
}
/**
* @param array<string, mixed> $data
*/
public function book(Organization $organization, string $ownerRef, array $data, ?string $actorRef = null): Appointment
{
$appointment = Appointment::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $data['branch_id'],
'patient_id' => $data['patient_id'],
'practitioner_id' => $data['practitioner_id'] ?? null,
'department_id' => $data['department_id'] ?? null,
'type' => Appointment::TYPE_SCHEDULED,
'status' => Appointment::STATUS_SCHEDULED,
'scheduled_at' => $data['scheduled_at'],
'reason' => $data['reason'] ?? null,
'notes' => $data['notes'] ?? null,
'created_by' => $actorRef,
]);
AuditLogger::record($ownerRef, 'appointment.created', $organization->id, $actorRef, Appointment::class, $appointment->id);
return $appointment->load(['patient', 'practitioner', 'branch']);
}
/**
* @param array<string, mixed> $data
*/
public function walkIn(Organization $organization, string $ownerRef, array $data, ?string $actorRef = null): Appointment
{
$visit = $this->visits->checkIn(
$organization,
$ownerRef,
Patient::findOrFail($data['patient_id']),
(int) $data['branch_id'],
$actorRef,
);
$appointment = Appointment::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $data['branch_id'],
'patient_id' => $data['patient_id'],
'practitioner_id' => $data['practitioner_id'] ?? null,
'department_id' => $data['department_id'] ?? null,
'visit_id' => $visit->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_WAITING,
'scheduled_at' => now(),
'checked_in_at' => now(),
'waiting_at' => now(),
'queue_position' => $this->nextQueuePosition($ownerRef, (int) $data['branch_id']),
'reason' => $data['reason'] ?? 'Walk-in',
'notes' => $data['notes'] ?? null,
'created_by' => $actorRef,
]);
AuditLogger::record($ownerRef, 'appointment.walk_in', $organization->id, $actorRef, Appointment::class, $appointment->id);
return $appointment->load(['patient', 'practitioner', 'branch', 'visit']);
}
public function checkIn(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
{
$this->assertTransition($appointment, Appointment::STATUS_SCHEDULED);
$visit = $this->visits->checkIn(
$appointment->organization,
$ownerRef,
$appointment->patient,
$appointment->branch_id,
$actorRef,
);
$appointment->update([
'visit_id' => $visit->id,
'status' => Appointment::STATUS_WAITING,
'checked_in_at' => now(),
'waiting_at' => now(),
'queue_position' => $this->nextQueuePosition($ownerRef, $appointment->branch_id),
]);
AuditLogger::record($ownerRef, 'appointment.checked_in', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
return $appointment->fresh(['patient', 'practitioner', 'visit']);
}
public function markWaiting(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
{
if (! in_array($appointment->status, [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_SCHEDULED], true)) {
$this->assertTransition($appointment, Appointment::STATUS_CHECKED_IN);
}
$updates = [
'status' => Appointment::STATUS_WAITING,
'waiting_at' => $appointment->waiting_at ?? now(),
];
if ($appointment->queue_position === null) {
$updates['queue_position'] = $this->nextQueuePosition($ownerRef, $appointment->branch_id);
}
$appointment->update($updates);
AuditLogger::record($ownerRef, 'appointment.waiting', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
return $appointment->fresh(['patient', 'practitioner']);
}
public function startConsultation(Appointment $appointment, string $ownerRef, ?int $practitionerId = null, ?string $actorRef = null): Appointment
{
$this->assertTransition($appointment, Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN);
if ($appointment->visit_id === null) {
$visit = $this->visits->checkIn(
$appointment->organization,
$ownerRef,
$appointment->patient,
$appointment->branch_id,
$actorRef,
);
$appointment->update(['visit_id' => $visit->id, 'checked_in_at' => now()]);
}
$appointment->visit->update(['status' => Visit::STATUS_IN_PROGRESS]);
$appointment->update([
'status' => Appointment::STATUS_IN_CONSULTATION,
'started_at' => now(),
'practitioner_id' => $practitionerId ?? $appointment->practitioner_id,
]);
AuditLogger::record($ownerRef, 'appointment.in_consultation', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
return $appointment->fresh(['patient', 'practitioner', 'visit']);
}
public function complete(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
{
$this->assertTransition($appointment, Appointment::STATUS_IN_CONSULTATION);
$appointment->update([
'status' => Appointment::STATUS_COMPLETED,
'completed_at' => now(),
]);
if ($appointment->visit) {
$this->visits->complete($appointment->visit, $ownerRef, $actorRef);
}
AuditLogger::record($ownerRef, 'appointment.completed', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
return $appointment->fresh(['patient', 'practitioner', 'visit']);
}
public function cancel(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
{
if (in_array($appointment->status, [Appointment::STATUS_COMPLETED, Appointment::STATUS_CANCELLED], true)) {
throw new InvalidArgumentException('Appointment cannot be cancelled.');
}
$appointment->update([
'status' => Appointment::STATUS_CANCELLED,
'cancelled_at' => now(),
]);
AuditLogger::record($ownerRef, 'appointment.cancelled', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
return $appointment->fresh(['patient', 'practitioner']);
}
public function markNoShow(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Appointment
{
$this->assertTransition($appointment, Appointment::STATUS_SCHEDULED);
$appointment->update(['status' => Appointment::STATUS_NO_SHOW]);
AuditLogger::record($ownerRef, 'appointment.no_show', $appointment->organization_id, $actorRef, Appointment::class, $appointment->id);
return $appointment->fresh(['patient', 'practitioner']);
}
public function callNext(string $ownerRef, int $branchId, ?int $practitionerId = null): ?Appointment
{
$next = $this->queue($ownerRef, $branchId, $practitionerId)->first();
return $next;
}
protected function nextQueuePosition(string $ownerRef, int $branchId): int
{
$max = Appointment::owned($ownerRef)
->where('branch_id', $branchId)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN])
->max('queue_position');
return ($max ?? 0) + 1;
}
protected function assertTransition(Appointment $appointment, string ...$allowedFrom): void
{
if (! in_array($appointment->status, $allowedFrom, true)) {
throw new InvalidArgumentException("Cannot transition from {$appointment->status}.");
}
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Services\Care;
use App\Models\AuditLog;
use App\Models\Member;
class AuditLogger
{
public static function record(
string $ownerRef,
string $action,
?int $organizationId = null,
?string $actorRef = null,
?string $subjectType = null,
?int $subjectId = null,
?array $metadata = null,
): AuditLog {
return AuditLog::record(
$ownerRef,
$action,
$organizationId,
$actorRef,
$subjectType,
$subjectId,
$metadata,
);
}
}
+307
View File
@@ -0,0 +1,307 @@
<?php
namespace App\Services\Care;
use App\Models\Bill;
use App\Models\BillLineItem;
use App\Models\InvestigationRequest;
use App\Models\Organization;
use App\Models\Payment;
use App\Models\Prescription;
use App\Models\Visit;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use InvalidArgumentException;
class BillService
{
public function __construct(
protected InvoiceNumberGenerator $invoices,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
{
return Bill::owned($ownerRef)->where('organization_id', $organizationId);
}
/**
* @param array<string, mixed> $filters
*/
public function list(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
{
$query = $this->queryForOrganization($ownerRef, $organizationId)
->with(['patient', 'branch', 'visit'])
->orderByDesc('created_at');
if ($branchId !== null) {
$query->where('branch_id', $branchId);
}
if ($status = $filters['status'] ?? null) {
$query->where('status', $status);
}
if ($patientId = $filters['patient_id'] ?? null) {
$query->where('patient_id', $patientId);
}
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
}
public function generateFromVisit(Visit $visit, string $ownerRef, ?string $actorRef = null): Bill
{
$existing = Bill::owned($ownerRef)
->where('visit_id', $visit->id)
->whereNotIn('status', [Bill::STATUS_VOID])
->first();
if ($existing) {
return $this->syncLineItemsFromVisit($existing, $ownerRef, $actorRef);
}
$visit->load(['patient', 'consultations', 'organization']);
$bill = Bill::create([
'owner_ref' => $ownerRef,
'organization_id' => $visit->organization_id,
'branch_id' => $visit->branch_id,
'visit_id' => $visit->id,
'patient_id' => $visit->patient_id,
'invoice_number' => $this->invoices->generate($visit->organization),
'status' => Bill::STATUS_OPEN,
'created_by' => $actorRef,
]);
$this->syncLineItemsFromVisit($bill, $ownerRef, $actorRef);
AuditLogger::record($ownerRef, 'bill.created', $visit->organization_id, $actorRef, Bill::class, $bill->id);
return $bill->fresh(['lineItems', 'patient', 'payments']);
}
public function syncLineItemsFromVisit(Bill $bill, string $ownerRef, ?string $actorRef = null): Bill
{
if (in_array($bill->status, [Bill::STATUS_PAID, Bill::STATUS_VOID], true)) {
return $bill;
}
$visit = $bill->visit()->with(['consultations'])->firstOrFail();
$bill->lineItems()->whereNotNull('source_type')->delete();
if ($visit->consultations()->where('status', 'completed')->exists()) {
$fee = (int) config('care.billing.consultation_fee_minor', 5000);
$this->addLineItem($bill, $ownerRef, [
'type' => 'consultation',
'description' => 'Consultation fee',
'quantity' => 1,
'unit_price_minor' => $fee,
'source_type' => Visit::class,
'source_id' => $visit->id,
]);
}
InvestigationRequest::owned($ownerRef)
->where('visit_id', $visit->id)
->whereIn('status', [InvestigationRequest::STATUS_COMPLETED, InvestigationRequest::STATUS_DELIVERED])
->with('investigationType')
->get()
->each(function (InvestigationRequest $request) use ($bill, $ownerRef) {
$this->addLineItem($bill, $ownerRef, [
'type' => 'lab',
'description' => $request->investigationType->name,
'quantity' => 1,
'unit_price_minor' => $request->investigationType->price_minor,
'source_type' => InvestigationRequest::class,
'source_id' => $request->id,
]);
});
Prescription::owned($ownerRef)
->where('visit_id', $visit->id)
->where('status', Prescription::STATUS_DISPENSED)
->with('items.drug')
->get()
->each(function (Prescription $rx) use ($bill, $ownerRef) {
foreach ($rx->items as $item) {
if ($item->is_procedure) {
$this->addLineItem($bill, $ownerRef, [
'type' => 'procedure',
'description' => $item->name,
'quantity' => 1,
'unit_price_minor' => 0,
'source_type' => Prescription::class,
'source_id' => $rx->id,
]);
continue;
}
$price = $item->drug?->unit_price_minor ?? 0;
$qty = max(1, (int) preg_replace('/\D/', '', (string) $item->quantity) ?: 1);
$this->addLineItem($bill, $ownerRef, [
'type' => 'pharmacy',
'description' => $item->name,
'quantity' => $qty,
'unit_price_minor' => $price,
'source_type' => Prescription::class,
'source_id' => $rx->id,
]);
}
});
$this->recalculate($bill);
AuditLogger::record($ownerRef, 'bill.updated', $bill->organization_id, $actorRef, Bill::class, $bill->id);
return $bill->fresh(['lineItems', 'payments', 'patient']);
}
/**
* @param array<string, mixed> $data
*/
public function addLineItem(Bill $bill, string $ownerRef, array $data): BillLineItem
{
$this->assertEditable($bill);
$quantity = max(1, (int) ($data['quantity'] ?? 1));
$unitPrice = (int) ($data['unit_price_minor'] ?? 0);
return BillLineItem::create([
'owner_ref' => $ownerRef,
'bill_id' => $bill->id,
'type' => $data['type'] ?? 'misc',
'description' => $data['description'],
'quantity' => $quantity,
'unit_price_minor' => $unitPrice,
'total_minor' => $quantity * $unitPrice,
'source_type' => $data['source_type'] ?? null,
'source_id' => $data['source_id'] ?? null,
]);
}
/**
* @param array<string, mixed> $data
*/
public function addManualLineItem(Bill $bill, string $ownerRef, array $data, ?string $actorRef = null): Bill
{
$this->addLineItem($bill, $ownerRef, $data);
$this->recalculate($bill);
AuditLogger::record($ownerRef, 'bill.line_item_added', $bill->organization_id, $actorRef, Bill::class, $bill->id);
return $bill->fresh(['lineItems', 'payments']);
}
public function applyAdjustments(Bill $bill, string $ownerRef, int $discountMinor = 0, int $taxMinor = 0, ?string $actorRef = null): Bill
{
$this->assertEditable($bill);
$bill->update([
'discount_minor' => max(0, $discountMinor),
'tax_minor' => max(0, $taxMinor),
]);
$this->recalculate($bill);
AuditLogger::record($ownerRef, 'bill.updated', $bill->organization_id, $actorRef, Bill::class, $bill->id);
return $bill->fresh(['lineItems', 'payments']);
}
/**
* @param array<string, mixed> $data
*/
public function recordPayment(Bill $bill, string $ownerRef, array $data, ?string $actorRef = null): Payment
{
if ($bill->status === Bill::STATUS_VOID) {
throw new InvalidArgumentException('Cannot pay a void bill.');
}
$amount = (int) $data['amount_minor'];
if ($amount <= 0) {
throw new InvalidArgumentException('Payment amount must be positive.');
}
$payment = Payment::create([
'owner_ref' => $ownerRef,
'bill_id' => $bill->id,
'amount_minor' => $amount,
'method' => $data['method'] ?? 'cash',
'reference' => $data['reference'] ?? null,
'paid_at' => $data['paid_at'] ?? now(),
'recorded_by' => $actorRef,
'notes' => $data['notes'] ?? null,
]);
$bill->refresh();
$paid = (int) $bill->payments()->sum('amount_minor');
$balance = max(0, $bill->total_minor - $paid);
$status = Bill::STATUS_OPEN;
if ($paid > 0 && $balance > 0) {
$status = Bill::STATUS_PARTIAL;
} elseif ($balance === 0 && $bill->total_minor > 0) {
$status = Bill::STATUS_PAID;
} elseif ($paid >= $bill->total_minor) {
$status = Bill::STATUS_PAID;
}
$bill->update([
'amount_paid_minor' => $paid,
'balance_minor' => $balance,
'status' => $status,
]);
AuditLogger::record($ownerRef, 'payment.recorded', $bill->organization_id, $actorRef, Payment::class, $payment->id, [
'bill_id' => $bill->id,
'amount_minor' => $amount,
]);
return $payment;
}
public function void(Bill $bill, string $ownerRef, ?string $actorRef = null): Bill
{
if ($bill->status === Bill::STATUS_PAID) {
throw new InvalidArgumentException('Paid bills cannot be voided.');
}
$bill->update(['status' => Bill::STATUS_VOID, 'balance_minor' => 0]);
AuditLogger::record($ownerRef, 'bill.voided', $bill->organization_id, $actorRef, Bill::class, $bill->id);
return $bill->fresh();
}
protected function recalculate(Bill $bill): void
{
$bill->refresh();
$subtotal = (int) $bill->lineItems()->sum('total_minor');
$total = max(0, $subtotal - $bill->discount_minor + $bill->tax_minor);
$paid = (int) $bill->payments()->sum('amount_minor');
$balance = max(0, $total - $paid);
$status = $bill->status;
if ($status !== Bill::STATUS_VOID) {
if ($paid === 0) {
$status = Bill::STATUS_OPEN;
} elseif ($balance > 0) {
$status = Bill::STATUS_PARTIAL;
} else {
$status = Bill::STATUS_PAID;
}
}
$bill->update([
'subtotal_minor' => $subtotal,
'total_minor' => $total,
'amount_paid_minor' => $paid,
'balance_minor' => $balance,
'status' => $status,
]);
}
protected function assertEditable(Bill $bill): void
{
if (in_array($bill->status, [Bill::STATUS_PAID, Bill::STATUS_VOID], true)) {
throw new InvalidArgumentException('Bill cannot be modified.');
}
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Services\Care;
use App\Models\Member;
class CarePermissions
{
/** @var array<string, list<string>> */
protected array $roleAbilities = [
'super_admin' => ['*'],
'hospital_admin' => ['*'],
'receptionist' => [
'dashboard.view', 'patients.view', 'patients.manage',
'appointments.view', 'appointments.manage', 'queue.manage',
],
'doctor' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'investigations.request', 'prescriptions.manage', 'lab.results.view',
],
'nurse' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'vitals.manage', 'queue.manage',
],
'lab_technician' => [
'dashboard.view', 'patients.view', 'lab.view', 'lab.manage',
],
'pharmacist' => [
'dashboard.view', 'patients.view', 'prescriptions.view', 'prescriptions.dispense',
'pharmacy.view', 'pharmacy.manage',
],
'cashier' => [
'dashboard.view', 'patients.view', 'bills.view', 'bills.manage', 'payments.manage',
],
'accountant' => [
'dashboard.view', 'bills.view', 'reports.finance.view', 'reports.finance.export',
'audit.view', 'audit.export',
],
];
/** @var list<string> */
protected array $adminAbilities = [
'admin.branches.view', 'admin.branches.manage',
'admin.departments.view', 'admin.departments.manage',
'admin.members.view', 'admin.members.manage',
'settings.view', 'settings.manage',
'audit.view', 'audit.export',
];
public function can(?Member $member, string $ability): bool
{
if ($member === null) {
return false;
}
$abilities = $this->roleAbilities[$member->role] ?? [];
if (in_array('*', $abilities, true)) {
return true;
}
return in_array($ability, $abilities, true);
}
public function isAdmin(?Member $member): bool
{
return $member !== null && in_array($member->role, ['super_admin', 'hospital_admin'], true);
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace App\Services\Care;
use App\Models\Appointment;
use App\Models\Consultation;
use App\Models\ConsultationDocument;
use App\Models\Diagnosis;
use App\Models\VitalSign;
use App\Models\Visit;
use Illuminate\Http\UploadedFile;
class ConsultationService
{
public function __construct(
protected AppointmentService $appointments,
) {}
public function startFromAppointment(Appointment $appointment, string $ownerRef, ?string $actorRef = null): Consultation
{
if ($appointment->status !== Appointment::STATUS_IN_CONSULTATION) {
$this->appointments->startConsultation(
$appointment,
$ownerRef,
$appointment->practitioner_id,
$actorRef,
);
$appointment->refresh();
}
$existing = Consultation::where('appointment_id', $appointment->id)->first();
if ($existing) {
return $existing->load(['vitalSigns', 'diagnoses', 'documents', 'patient', 'practitioner']);
}
$consultation = Consultation::create([
'owner_ref' => $ownerRef,
'visit_id' => $appointment->visit_id,
'appointment_id' => $appointment->id,
'practitioner_id' => $appointment->practitioner_id,
'patient_id' => $appointment->patient_id,
'status' => Consultation::STATUS_DRAFT,
'started_at' => now(),
]);
AuditLogger::record($ownerRef, 'consultation.started', $appointment->organization_id, $actorRef, Consultation::class, $consultation->id);
return $consultation->load(['patient', 'practitioner', 'visit']);
}
/**
* @param array<string, mixed> $data
*/
public function save(Consultation $consultation, string $ownerRef, array $data, ?string $actorRef = null): Consultation
{
$consultation->update([
'symptoms' => $data['symptoms'] ?? $consultation->symptoms,
'clinical_notes' => $data['clinical_notes'] ?? $consultation->clinical_notes,
'practitioner_id' => $data['practitioner_id'] ?? $consultation->practitioner_id,
]);
if (array_key_exists('vitals', $data) && is_array($data['vitals'])) {
$this->saveVitals($consultation, $ownerRef, $data['vitals'], $actorRef);
}
if (array_key_exists('diagnoses', $data)) {
$this->syncDiagnoses($consultation, $ownerRef, $data['diagnoses']);
}
if (! empty($data['documents'])) {
$this->storeDocuments($consultation, $ownerRef, $data['documents'], $actorRef);
}
AuditLogger::record($ownerRef, 'consultation.updated', $consultation->visit->organization_id, $actorRef, Consultation::class, $consultation->id);
return $consultation->fresh(['vitalSigns', 'diagnoses', 'documents', 'patient', 'practitioner', 'visit', 'appointment']);
}
public function complete(Consultation $consultation, string $ownerRef, ?string $actorRef = null): Consultation
{
$consultation->update([
'status' => Consultation::STATUS_COMPLETED,
'completed_at' => now(),
'completed_by' => $actorRef,
]);
if ($consultation->appointment) {
$this->appointments->complete($consultation->appointment, $ownerRef, $actorRef);
} elseif ($consultation->visit) {
app(VisitService::class)->complete($consultation->visit, $ownerRef, $actorRef);
}
AuditLogger::record($ownerRef, 'consultation.completed', $consultation->visit->organization_id, $actorRef, Consultation::class, $consultation->id);
return $consultation->fresh(['vitalSigns', 'diagnoses', 'documents', 'patient', 'practitioner']);
}
/**
* @param array<string, mixed> $vitals
*/
protected function saveVitals(Consultation $consultation, string $ownerRef, array $vitals, ?string $actorRef): void
{
if (empty(array_filter($vitals))) {
return;
}
VitalSign::create([
'owner_ref' => $ownerRef,
'consultation_id' => $consultation->id,
'bp_systolic' => $vitals['bp_systolic'] ?? null,
'bp_diastolic' => $vitals['bp_diastolic'] ?? null,
'pulse' => $vitals['pulse'] ?? null,
'temperature' => $vitals['temperature'] ?? null,
'weight_kg' => $vitals['weight_kg'] ?? null,
'height_cm' => $vitals['height_cm'] ?? null,
'spo2' => $vitals['spo2'] ?? null,
'respiratory_rate' => $vitals['respiratory_rate'] ?? null,
'recorded_by' => $actorRef,
'recorded_at' => now(),
]);
}
/**
* @param array<int, array<string, mixed>> $rows
*/
protected function syncDiagnoses(Consultation $consultation, string $ownerRef, array $rows): void
{
$consultation->diagnoses()->delete();
foreach ($rows as $row) {
if (empty($row['description'])) {
continue;
}
Diagnosis::create([
'owner_ref' => $ownerRef,
'consultation_id' => $consultation->id,
'code' => $row['code'] ?? null,
'description' => $row['description'],
'is_primary' => (bool) ($row['is_primary'] ?? false),
'notes' => $row['notes'] ?? null,
]);
}
}
/**
* @param array<int, UploadedFile> $files
*/
protected function storeDocuments(Consultation $consultation, string $ownerRef, array $files, ?string $actorRef): void
{
foreach ($files as $file) {
if (! $file instanceof UploadedFile) {
continue;
}
$path = $file->store("care/consultations/{$consultation->id}/documents", 'public');
ConsultationDocument::create([
'owner_ref' => $ownerRef,
'consultation_id' => $consultation->id,
'file_path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'uploaded_by' => $actorRef,
]);
}
}
}
+398
View File
@@ -0,0 +1,398 @@
<?php
namespace App\Services\Care;
use App\Models\Consultation;
use App\Models\InvestigationAttachment;
use App\Models\InvestigationRequest;
use App\Models\InvestigationResult;
use App\Models\InvestigationResultValue;
use App\Models\InvestigationType;
use App\Models\Organization;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
class InvestigationService
{
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
{
return InvestigationRequest::owned($ownerRef)->where('organization_id', $organizationId);
}
/**
* @param array<string, mixed> $filters
*/
public function list(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
{
$query = $this->queryForOrganization($ownerRef, $organizationId)
->with(['patient', 'investigationType', 'practitioner', 'branch', 'result'])
->orderByDesc('created_at');
if ($branchId !== null) {
$query->where('branch_id', $branchId);
}
if ($status = $filters['status'] ?? null) {
$query->where('status', $status);
}
if ($patientId = $filters['patient_id'] ?? null) {
$query->where('patient_id', $patientId);
}
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
}
/**
* @return Collection<int, InvestigationRequest>
*/
public function workQueue(string $ownerRef, int $branchId, ?string $status = null): Collection
{
$query = InvestigationRequest::owned($ownerRef)
->where('branch_id', $branchId)
->whereIn('status', InvestigationRequest::activeStatuses())
->with(['patient', 'investigationType', 'assignedMember', 'result'])
->orderByRaw("CASE priority WHEN 'urgent' THEN 0 ELSE 1 END")
->orderBy('created_at');
if ($status) {
$query->where('status', $status);
}
return $query->get();
}
/**
* @param array<int, int> $typeIds
* @return Collection<int, InvestigationRequest>
*/
public function requestFromConsultation(
Consultation $consultation,
string $ownerRef,
array $typeIds,
?string $clinicalNotes = null,
string $priority = 'routine',
?string $actorRef = null,
): Collection {
$consultation->loadMissing('visit');
$created = new Collection();
foreach ($typeIds as $typeId) {
$type = InvestigationType::owned($ownerRef)->findOrFail($typeId);
$request = InvestigationRequest::create([
'owner_ref' => $ownerRef,
'organization_id' => $consultation->visit->organization_id,
'branch_id' => $consultation->visit->branch_id,
'visit_id' => $consultation->visit_id,
'consultation_id' => $consultation->id,
'patient_id' => $consultation->patient_id,
'investigation_type_id' => $type->id,
'practitioner_id' => $consultation->practitioner_id,
'status' => InvestigationRequest::STATUS_PENDING,
'priority' => $priority,
'clinical_notes' => $clinicalNotes,
'requested_by' => $actorRef,
]);
AuditLogger::record(
$ownerRef,
'investigation.requested',
$consultation->visit->organization_id,
$actorRef,
InvestigationRequest::class,
$request->id,
);
$created->push($request->load('investigationType'));
}
return $created;
}
public function collectSample(
InvestigationRequest $request,
string $ownerRef,
?string $barcode = null,
?string $actorRef = null,
): InvestigationRequest {
$this->assertStatus($request, InvestigationRequest::STATUS_PENDING);
$request->update([
'status' => InvestigationRequest::STATUS_SAMPLE_COLLECTED,
'sample_barcode' => $barcode ?? $this->generateBarcode($request),
'sample_collected_at' => now(),
'sample_collected_by' => $actorRef,
]);
AuditLogger::record($ownerRef, 'investigation.sample_collected', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
return $request->fresh(['patient', 'investigationType']);
}
public function startProcessing(
InvestigationRequest $request,
string $ownerRef,
?int $assignedMemberId = null,
?string $actorRef = null,
): InvestigationRequest {
$this->assertStatus($request, InvestigationRequest::STATUS_SAMPLE_COLLECTED);
$request->update([
'status' => InvestigationRequest::STATUS_IN_PROGRESS,
'assigned_member_id' => $assignedMemberId,
]);
InvestigationResult::firstOrCreate(
['investigation_request_id' => $request->id],
['owner_ref' => $ownerRef, 'entered_by' => $actorRef, 'status' => InvestigationResult::STATUS_DRAFT],
);
AuditLogger::record($ownerRef, 'investigation.in_progress', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
return $request->fresh(['patient', 'investigationType', 'result']);
}
/**
* @param array<string, mixed> $data
*/
public function enterResults(
InvestigationRequest $request,
string $ownerRef,
array $data,
?string $actorRef = null,
): InvestigationResult {
if (! in_array($request->status, [InvestigationRequest::STATUS_IN_PROGRESS, InvestigationRequest::STATUS_AWAITING_REVIEW], true)) {
throw new InvalidArgumentException('Results can only be entered when processing.');
}
$result = InvestigationResult::firstOrCreate(
['investigation_request_id' => $request->id],
['owner_ref' => $ownerRef, 'entered_by' => $actorRef],
);
$type = $request->investigationType;
$isAbnormal = false;
if (! empty($data['values']) && is_array($data['values'])) {
$result->values()->delete();
foreach ($data['values'] as $row) {
if (empty($row['parameter'])) {
continue;
}
$abnormal = $this->isValueAbnormal(
$row['value'] ?? null,
$row['reference_low'] ?? $type->reference_low,
$row['reference_high'] ?? $type->reference_high,
);
$isAbnormal = $isAbnormal || $abnormal;
InvestigationResultValue::create([
'owner_ref' => $ownerRef,
'investigation_result_id' => $result->id,
'parameter' => $row['parameter'],
'value' => $row['value'] ?? null,
'unit' => $row['unit'] ?? $type->unit,
'reference_low' => $row['reference_low'] ?? $type->reference_low,
'reference_high' => $row['reference_high'] ?? $type->reference_high,
'reference_text' => $row['reference_text'] ?? $type->reference_text,
'is_abnormal' => $abnormal,
]);
}
} elseif (! empty($data['value'])) {
$result->values()->delete();
$abnormal = $this->isValueAbnormal($data['value'], $type->reference_low, $type->reference_high);
$isAbnormal = $abnormal;
InvestigationResultValue::create([
'owner_ref' => $ownerRef,
'investigation_result_id' => $result->id,
'parameter' => $type->name,
'value' => $data['value'],
'unit' => $type->unit,
'reference_low' => $type->reference_low,
'reference_high' => $type->reference_high,
'reference_text' => $type->reference_text,
'is_abnormal' => $abnormal,
]);
}
$result->update([
'result_summary' => $data['result_summary'] ?? $result->result_summary,
'interpretation' => $data['interpretation'] ?? $result->interpretation,
'is_abnormal' => $isAbnormal,
'entered_by' => $actorRef ?? $result->entered_by,
'status' => InvestigationResult::STATUS_DRAFT,
]);
if (! empty($data['attachments'])) {
$this->storeAttachments($result, $ownerRef, $data['attachments'], $actorRef);
}
$request->update(['status' => InvestigationRequest::STATUS_AWAITING_REVIEW]);
AuditLogger::record($ownerRef, 'investigation.results_entered', $request->organization_id, $actorRef, InvestigationResult::class, $result->id);
return $result->fresh(['values', 'attachments']);
}
public function approve(InvestigationRequest $request, string $ownerRef, ?string $actorRef = null): InvestigationRequest
{
$this->assertStatus($request, InvestigationRequest::STATUS_AWAITING_REVIEW);
$result = $request->result;
abort_unless($result, 422, 'No results to approve.');
$result->update([
'status' => InvestigationResult::STATUS_APPROVED,
'approved_by' => $actorRef,
'approved_at' => now(),
]);
$request->update([
'status' => InvestigationRequest::STATUS_COMPLETED,
'completed_at' => now(),
'approved_by' => $actorRef,
'approved_at' => now(),
]);
AuditLogger::record($ownerRef, 'investigation.approved', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
return $request->fresh(['patient', 'investigationType', 'result.values']);
}
public function deliver(InvestigationRequest $request, string $ownerRef, ?string $actorRef = null): InvestigationRequest
{
$this->assertStatus($request, InvestigationRequest::STATUS_COMPLETED);
$request->update([
'status' => InvestigationRequest::STATUS_DELIVERED,
'delivered_at' => now(),
]);
AuditLogger::record($ownerRef, 'investigation.delivered', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
return $request->fresh(['patient', 'investigationType', 'result']);
}
public function cancel(InvestigationRequest $request, string $ownerRef, ?string $actorRef = null): InvestigationRequest
{
if (in_array($request->status, [InvestigationRequest::STATUS_COMPLETED, InvestigationRequest::STATUS_DELIVERED], true)) {
throw new InvalidArgumentException('Cannot cancel a completed investigation.');
}
$request->update(['status' => InvestigationRequest::STATUS_CANCELLED]);
AuditLogger::record($ownerRef, 'investigation.cancelled', $request->organization_id, $actorRef, InvestigationRequest::class, $request->id);
return $request->fresh();
}
/**
* @param array<string, mixed> $data
*/
public function createType(Organization $organization, string $ownerRef, array $data): InvestigationType
{
$type = InvestigationType::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'name' => $data['name'],
'code' => $data['code'] ?? null,
'category' => $data['category'],
'description' => $data['description'] ?? null,
'unit' => $data['unit'] ?? null,
'reference_low' => $data['reference_low'] ?? null,
'reference_high' => $data['reference_high'] ?? null,
'reference_text' => $data['reference_text'] ?? null,
'price_minor' => (int) ($data['price_minor'] ?? 0),
'is_active' => (bool) ($data['is_active'] ?? true),
]);
AuditLogger::record($ownerRef, 'investigation_type.created', $organization->id, null, InvestigationType::class, $type->id);
return $type;
}
/**
* @param array<string, mixed> $data
*/
public function updateType(InvestigationType $type, string $ownerRef, array $data): InvestigationType
{
$type->update([
'name' => $data['name'],
'code' => $data['code'] ?? null,
'category' => $data['category'],
'description' => $data['description'] ?? null,
'unit' => $data['unit'] ?? null,
'reference_low' => $data['reference_low'] ?? null,
'reference_high' => $data['reference_high'] ?? null,
'reference_text' => $data['reference_text'] ?? null,
'price_minor' => (int) ($data['price_minor'] ?? 0),
'is_active' => (bool) ($data['is_active'] ?? true),
]);
AuditLogger::record($ownerRef, 'investigation_type.updated', $type->organization_id, null, InvestigationType::class, $type->id);
return $type;
}
protected function assertStatus(InvestigationRequest $request, string ...$allowed): void
{
if (! in_array($request->status, $allowed, true)) {
throw new InvalidArgumentException("Cannot transition from {$request->status}.");
}
}
protected function isValueAbnormal(?string $value, mixed $low, mixed $high): bool
{
if ($value === null || $value === '') {
return false;
}
if (! is_numeric($value)) {
return false;
}
$numeric = (float) $value;
if ($low !== null && $numeric < (float) $low) {
return true;
}
if ($high !== null && $numeric > (float) $high) {
return true;
}
return false;
}
protected function generateBarcode(InvestigationRequest $request): string
{
return 'LAB-'.str_pad((string) $request->id, 6, '0', STR_PAD_LEFT);
}
/**
* @param array<int, UploadedFile> $files
*/
protected function storeAttachments(InvestigationResult $result, string $ownerRef, array $files, ?string $actorRef): void
{
foreach ($files as $file) {
if (! $file instanceof UploadedFile) {
continue;
}
$path = $file->store("care/investigations/{$result->id}/attachments", 'public');
InvestigationAttachment::create([
'owner_ref' => $ownerRef,
'investigation_result_id' => $result->id,
'file_path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'uploaded_by' => $actorRef,
]);
}
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Services\Care;
use App\Models\Organization;
use App\Models\Bill;
use Illuminate\Support\Facades\DB;
class InvoiceNumberGenerator
{
public function generate(Organization $organization): string
{
$prefix = config('care.invoice_number.prefix', 'INV');
$year = now()->format('Y');
return DB::transaction(function () use ($organization, $prefix, $year) {
$latest = Bill::withTrashed()
->where('organization_id', $organization->id)
->where('invoice_number', 'like', "{$prefix}-{$year}-%")
->orderByDesc('invoice_number')
->lockForUpdate()
->value('invoice_number');
$sequence = 1;
if ($latest && preg_match('/-(\d+)$/', $latest, $matches)) {
$sequence = (int) $matches[1] + 1;
}
return sprintf('%s-%s-%05d', $prefix, $year, $sequence);
});
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Services\Care;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Support\Str;
class OrganizationResolver
{
public function resolveForUser(User $user): ?Organization
{
$ref = $user->ownerRef();
$member = Member::where('user_ref', $ref)->first();
if ($member) {
return Organization::find($member->organization_id);
}
return Organization::owned($ref)->first();
}
public function forUser(User $user): Organization
{
return $this->resolveForUser($user)
?? throw new \RuntimeException('No organization for user.');
}
public function isOnboarded(User $user): bool
{
$organization = $this->resolveForUser($user);
return $organization !== null
&& (bool) data_get($organization->settings, 'onboarded', false);
}
public function memberFor(User $user, ?Organization $organization = null): ?Member
{
$organization ??= $this->resolveForUser($user);
if (! $organization) {
return null;
}
return Member::where('organization_id', $organization->id)
->where('user_ref', $user->ownerRef())
->first();
}
public function ensureOwnerMember(User $user, Organization $organization): Member
{
return Member::firstOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $user->ownerRef(),
],
[
'owner_ref' => $user->ownerRef(),
'role' => 'hospital_admin',
],
);
}
/**
* @param array<string, mixed> $data
*/
public function completeOnboarding(User $user, array $data): Organization
{
$ref = $user->ownerRef();
$organization = Organization::create([
'owner_ref' => $ref,
'name' => $data['organization_name'],
'slug' => Str::slug($data['organization_name']).'-'.substr($ref, 0, 6),
'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'),
'settings' => [
'onboarded' => true,
'facility_type' => $data['facility_type'] ?? 'clinic',
],
]);
$this->ensureOwnerMember($user, $organization);
$branch = Branch::create([
'owner_ref' => $ref,
'organization_id' => $organization->id,
'name' => $data['branch_name'],
'address' => $data['branch_address'] ?? null,
'phone' => $data['branch_phone'] ?? null,
'is_active' => true,
]);
Department::create([
'owner_ref' => $ref,
'branch_id' => $branch->id,
'name' => 'General Outpatient',
'type' => 'outpatient',
'is_active' => true,
]);
AuditLogger::record($ref, 'organization.created', $organization->id, $ref, Organization::class, $organization->id);
AuditLogger::record($ref, 'branch.created', $organization->id, $ref, Branch::class, $branch->id);
return $organization;
}
/** Branch ID the member may access; null = all branches. */
public function branchScope(?Member $member): ?int
{
if ($member === null) {
return null;
}
if (in_array($member->role, ['super_admin', 'hospital_admin', 'accountant'], true)) {
return null;
}
return $member->branch_id;
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Services\Care;
use App\Models\Organization;
use App\Models\Patient;
use Illuminate\Support\Facades\DB;
class PatientNumberGenerator
{
public function generate(Organization $organization): string
{
$prefix = config('care.patient_number.prefix', 'LC');
$year = now()->format('Y');
return DB::transaction(function () use ($organization, $prefix, $year) {
$latest = Patient::withTrashed()
->where('organization_id', $organization->id)
->where('patient_number', 'like', "{$prefix}-{$year}-%")
->orderByDesc('patient_number')
->lockForUpdate()
->value('patient_number');
$sequence = 1;
if ($latest && preg_match('/-(\d+)$/', $latest, $matches)) {
$sequence = (int) $matches[1] + 1;
}
return sprintf('%s-%s-%05d', $prefix, $year, $sequence);
});
}
}
+324
View File
@@ -0,0 +1,324 @@
<?php
namespace App\Services\Care;
use App\Models\EmergencyContact;
use App\Models\InsurancePolicy;
use App\Models\Consultation;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\PatientAllergy;
use App\Models\PatientAttachment;
use App\Models\PatientCondition;
use App\Models\PatientFamilyHistory;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
class PatientService
{
public function __construct(
protected PatientNumberGenerator $numbers,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
{
return Patient::owned($ownerRef)->where('organization_id', $organizationId);
}
/**
* @param array<string, mixed> $filters
*/
public function search(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
{
$query = $this->queryForOrganization($ownerRef, $organizationId)
->with(['branch'])
->orderByDesc('created_at');
if ($branchId !== null) {
$query->where('branch_id', $branchId);
}
if ($q = trim((string) ($filters['q'] ?? ''))) {
$query->where(function (Builder $inner) use ($q) {
$inner->where('patient_number', 'like', "%{$q}%")
->orWhere('phone', 'like', "%{$q}%")
->orWhere('national_id', 'like', "%{$q}%")
->orWhere('first_name', 'like', "%{$q}%")
->orWhere('last_name', 'like', "%{$q}%")
->orWhere('other_names', 'like', "%{$q}%")
->orWhereRaw("concat(first_name, ' ', coalesce(other_names, ''), ' ', last_name) like ?", ["%{$q}%"]);
});
}
if ($patientNumber = trim((string) ($filters['patient_number'] ?? ''))) {
$query->where('patient_number', $patientNumber);
}
if ($phone = trim((string) ($filters['phone'] ?? ''))) {
$query->where('phone', 'like', "%{$phone}%");
}
if ($nationalId = trim((string) ($filters['national_id'] ?? ''))) {
$query->where('national_id', $nationalId);
}
if ($dob = $filters['date_of_birth'] ?? null) {
$query->whereDate('date_of_birth', Carbon::parse($dob)->toDateString());
}
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
}
/**
* @param array<string, mixed> $data
*/
public function create(Organization $organization, string $ownerRef, array $data, ?string $actorRef = null): Patient
{
$patient = Patient::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $data['branch_id'] ?? null,
'patient_number' => $this->numbers->generate($organization),
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'other_names' => $data['other_names'] ?? null,
'gender' => $data['gender'] ?? null,
'date_of_birth' => $data['date_of_birth'] ?? null,
'phone' => $data['phone'] ?? null,
'email' => $data['email'] ?? null,
'national_id' => $data['national_id'] ?? null,
'address' => $data['address'] ?? null,
'city' => $data['city'] ?? null,
'region' => $data['region'] ?? null,
'notes' => $data['notes'] ?? null,
]);
$this->syncRelated($patient, $ownerRef, $data);
$this->storeAttachments($patient, $ownerRef, $data['attachments'] ?? [], $actorRef);
AuditLogger::record($ownerRef, 'patient.created', $organization->id, $actorRef, Patient::class, $patient->id);
return $patient->fresh([
'allergies', 'conditions', 'familyHistory', 'emergencyContacts',
'insurancePolicies', 'attachments', 'branch',
]);
}
/**
* @param array<string, mixed> $data
*/
public function update(Patient $patient, string $ownerRef, array $data, ?string $actorRef = null): Patient
{
$patient->update([
'branch_id' => $data['branch_id'] ?? $patient->branch_id,
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'other_names' => $data['other_names'] ?? null,
'gender' => $data['gender'] ?? null,
'date_of_birth' => $data['date_of_birth'] ?? null,
'phone' => $data['phone'] ?? null,
'email' => $data['email'] ?? null,
'national_id' => $data['national_id'] ?? null,
'address' => $data['address'] ?? null,
'city' => $data['city'] ?? null,
'region' => $data['region'] ?? null,
'notes' => $data['notes'] ?? null,
]);
$this->syncRelated($patient, $ownerRef, $data);
$this->storeAttachments($patient, $ownerRef, $data['attachments'] ?? [], $actorRef);
AuditLogger::record($ownerRef, 'patient.updated', $patient->organization_id, $actorRef, Patient::class, $patient->id);
return $patient->fresh([
'allergies', 'conditions', 'familyHistory', 'emergencyContacts',
'insurancePolicies', 'attachments', 'branch',
]);
}
public function delete(Patient $patient, string $ownerRef, ?string $actorRef = null): void
{
$organizationId = $patient->organization_id;
$patientId = $patient->id;
foreach ($patient->attachments as $attachment) {
Storage::disk('public')->delete($attachment->file_path);
}
$patient->delete();
AuditLogger::record($ownerRef, 'patient.deleted', $organizationId, $actorRef, Patient::class, $patientId);
}
/**
* @return array<string, mixed>
*/
public function dashboard(Patient $patient): array
{
$patient->load([
'branch', 'allergies', 'conditions', 'familyHistory',
'emergencyContacts', 'insurancePolicies', 'attachments',
]);
$visits = $patient->visits()
->with(['branch'])
->orderByDesc('checked_in_at')
->limit(10)
->get();
$consultations = Consultation::owned($patient->owner_ref)
->where('patient_id', $patient->id)
->with(['practitioner', 'diagnoses'])
->orderByDesc('started_at')
->limit(10)
->get();
$laboratory = $patient->investigationRequests()
->with(['investigationType', 'result'])
->whereIn('status', [
\App\Models\InvestigationRequest::STATUS_COMPLETED,
\App\Models\InvestigationRequest::STATUS_DELIVERED,
])
->orderByDesc('completed_at')
->limit(10)
->get();
$prescriptions = $patient->prescriptions()
->with(['practitioner', 'items'])
->orderByDesc('created_at')
->limit(10)
->get();
$invoices = $patient->bills()
->with(['branch', 'payments'])
->orderByDesc('created_at')
->limit(10)
->get();
return [
'patient' => $patient,
'visits' => $visits,
'consultations' => $consultations,
'laboratory' => $laboratory,
'prescriptions' => $prescriptions,
'invoices' => $invoices,
];
}
/**
* @param array<string, mixed> $data
*/
protected function syncRelated(Patient $patient, string $ownerRef, array $data): void
{
if (array_key_exists('allergies', $data)) {
$patient->allergies()->delete();
foreach ($data['allergies'] as $row) {
if (empty($row['allergen'])) {
continue;
}
PatientAllergy::create([
'owner_ref' => $ownerRef,
'patient_id' => $patient->id,
'allergen' => $row['allergen'],
'severity' => $row['severity'] ?? 'unknown',
'notes' => $row['notes'] ?? null,
]);
}
}
if (array_key_exists('conditions', $data)) {
$patient->conditions()->delete();
foreach ($data['conditions'] as $row) {
if (empty($row['condition'])) {
continue;
}
PatientCondition::create([
'owner_ref' => $ownerRef,
'patient_id' => $patient->id,
'condition' => $row['condition'],
'onset_date' => $row['onset_date'] ?? null,
'is_chronic' => (bool) ($row['is_chronic'] ?? false),
'notes' => $row['notes'] ?? null,
]);
}
}
if (array_key_exists('family_history', $data)) {
$patient->familyHistory()->delete();
foreach ($data['family_history'] as $row) {
if (empty($row['condition'])) {
continue;
}
PatientFamilyHistory::create([
'owner_ref' => $ownerRef,
'patient_id' => $patient->id,
'relation' => $row['relation'] ?? 'other',
'condition' => $row['condition'],
'notes' => $row['notes'] ?? null,
]);
}
}
if (array_key_exists('emergency_contacts', $data)) {
$patient->emergencyContacts()->delete();
foreach ($data['emergency_contacts'] as $row) {
if (empty($row['name']) || empty($row['phone'])) {
continue;
}
EmergencyContact::create([
'owner_ref' => $ownerRef,
'patient_id' => $patient->id,
'name' => $row['name'],
'phone' => $row['phone'],
'relationship' => $row['relationship'] ?? null,
'is_primary' => (bool) ($row['is_primary'] ?? false),
]);
}
}
if (array_key_exists('insurance', $data)) {
$patient->insurancePolicies()->delete();
foreach ($data['insurance'] as $row) {
if (empty($row['provider_name'])) {
continue;
}
InsurancePolicy::create([
'owner_ref' => $ownerRef,
'patient_id' => $patient->id,
'provider_name' => $row['provider_name'],
'policy_number' => $row['policy_number'] ?? null,
'coverage_type' => $row['coverage_type'] ?? null,
'expiry_date' => $row['expiry_date'] ?? null,
'notes' => $row['notes'] ?? null,
]);
}
}
}
/**
* @param array<int, UploadedFile> $files
*/
protected function storeAttachments(Patient $patient, string $ownerRef, array $files, ?string $actorRef): void
{
foreach ($files as $file) {
if (! $file instanceof UploadedFile) {
continue;
}
$path = $file->store("care/patients/{$patient->id}/attachments", 'public');
PatientAttachment::create([
'owner_ref' => $ownerRef,
'patient_id' => $patient->id,
'file_path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'document_type' => 'other',
'uploaded_by' => $actorRef,
]);
}
}
}
+202
View File
@@ -0,0 +1,202 @@
<?php
namespace App\Services\Care;
use App\Models\DispensingRecord;
use App\Models\Drug;
use App\Models\DrugBatch;
use App\Models\Organization;
use App\Models\Prescription;
use App\Models\PrescriptionItem;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use InvalidArgumentException;
class PharmacyService
{
public function queryDrugs(string $ownerRef, int $organizationId): Builder
{
return Drug::owned($ownerRef)->where('organization_id', $organizationId);
}
/**
* @param array<string, mixed> $filters
*/
public function listDrugs(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
{
$query = $this->queryDrugs($ownerRef, $organizationId)
->with(['batches'])
->orderBy('name');
if ($branchId !== null) {
$query->where(function (Builder $q) use ($branchId) {
$q->where('branch_id', $branchId)->orWhereNull('branch_id');
});
}
if ($search = trim((string) ($filters['q'] ?? ''))) {
$query->where(function (Builder $inner) use ($search) {
$inner->where('name', 'like', "%{$search}%")
->orWhere('generic_name', 'like', "%{$search}%")
->orWhere('sku', 'like', "%{$search}%");
});
}
return $query->paginate((int) ($filters['per_page'] ?? 30))->withQueryString();
}
/**
* @param array<string, mixed> $data
*/
public function createDrug(Organization $organization, string $ownerRef, array $data): Drug
{
$drug = Drug::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $data['branch_id'] ?? null,
'name' => $data['name'],
'generic_name' => $data['generic_name'] ?? null,
'sku' => $data['sku'] ?? null,
'unit' => $data['unit'] ?? 'unit',
'unit_price_minor' => (int) ($data['unit_price_minor'] ?? 0),
'reorder_level' => (int) ($data['reorder_level'] ?? 10),
'is_active' => (bool) ($data['is_active'] ?? true),
]);
AuditLogger::record($ownerRef, 'drug.created', $organization->id, null, Drug::class, $drug->id);
return $drug;
}
/**
* @param array<string, mixed> $data
*/
public function updateDrug(Drug $drug, string $ownerRef, array $data): Drug
{
$drug->update([
'branch_id' => $data['branch_id'] ?? $drug->branch_id,
'name' => $data['name'],
'generic_name' => $data['generic_name'] ?? null,
'sku' => $data['sku'] ?? null,
'unit' => $data['unit'] ?? $drug->unit,
'unit_price_minor' => (int) ($data['unit_price_minor'] ?? 0),
'reorder_level' => (int) ($data['reorder_level'] ?? 10),
'is_active' => (bool) ($data['is_active'] ?? true),
]);
AuditLogger::record($ownerRef, 'drug.updated', $drug->organization_id, null, Drug::class, $drug->id);
return $drug->fresh(['batches']);
}
/**
* @param array<string, mixed> $data
*/
public function receiveBatch(Drug $drug, string $ownerRef, array $data, ?string $actorRef = null): DrugBatch
{
$batch = DrugBatch::create([
'owner_ref' => $ownerRef,
'drug_id' => $drug->id,
'batch_number' => $data['batch_number'],
'expiry_date' => $data['expiry_date'] ?? null,
'quantity_on_hand' => (int) ($data['quantity_on_hand'] ?? 0),
'cost_minor' => (int) ($data['cost_minor'] ?? 0),
'received_at' => now(),
]);
AuditLogger::record($ownerRef, 'drug.batch_received', $drug->organization_id, $actorRef, DrugBatch::class, $batch->id);
return $batch->load('drug');
}
/**
* @return Collection<int, Drug>
*/
public function lowStock(string $ownerRef, int $organizationId): Collection
{
return $this->queryDrugs($ownerRef, $organizationId)
->where('is_active', true)
->with('batches')
->get()
->filter(fn (Drug $drug) => $drug->stockOnHand() <= $drug->reorder_level);
}
/**
* @return Collection<int, DrugBatch>
*/
public function expiredBatches(string $ownerRef, int $organizationId): Collection
{
return DrugBatch::owned($ownerRef)
->whereHas('drug', fn (Builder $q) => $q->where('organization_id', $organizationId))
->whereNotNull('expiry_date')
->where('expiry_date', '<', now()->toDateString())
->where('quantity_on_hand', '>', 0)
->with('drug')
->get();
}
/**
* @param array<int, array<string, mixed>> $allocations
*/
public function dispensePrescription(
Prescription $prescription,
string $ownerRef,
array $allocations,
?string $actorRef = null,
): Prescription {
if ($prescription->status !== Prescription::STATUS_ACTIVE) {
throw new InvalidArgumentException('Only active prescriptions can be dispensed.');
}
$prescription->load('items');
foreach ($allocations as $row) {
if (empty($row['prescription_item_id']) || empty($row['drug_batch_id'])) {
continue;
}
$item = $prescription->items->firstWhere('id', (int) $row['prescription_item_id']);
if (! $item || $item->is_procedure) {
continue;
}
$qty = max(1, (int) ($row['quantity'] ?? 1));
$batch = DrugBatch::owned($ownerRef)->findOrFail((int) $row['drug_batch_id']);
if ($batch->isExpired()) {
throw new InvalidArgumentException("Batch {$batch->batch_number} is expired.");
}
if ($batch->quantity_on_hand < $qty) {
throw new InvalidArgumentException("Insufficient stock for {$batch->drug->name}.");
}
$batch->decrement('quantity_on_hand', $qty);
DispensingRecord::create([
'owner_ref' => $ownerRef,
'prescription_id' => $prescription->id,
'prescription_item_id' => $item->id,
'drug_batch_id' => $batch->id,
'quantity' => $qty,
'dispensed_by' => $actorRef,
'dispensed_at' => now(),
]);
if (! $item->drug_id) {
$item->update(['drug_id' => $batch->drug_id]);
}
}
$prescription->update([
'status' => Prescription::STATUS_DISPENSED,
'dispensed_at' => now(),
'dispensed_by' => $actorRef,
]);
AuditLogger::record($ownerRef, 'prescription.dispensed', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
return $prescription->fresh(['items', 'patient']);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Services\Care;
use App\Models\Organization;
class PlanService
{
public function plan(Organization $organization): string
{
return (string) data_get($organization->settings, 'plan', 'free');
}
public function isPro(Organization $organization): bool
{
return $this->plan($organization) === 'pro';
}
public function maxBranches(Organization $organization): ?int
{
$plan = config('care.plans.'.$this->plan($organization));
return $plan['max_branches'] ?? null;
}
public function canAddBranch(Organization $organization, int $currentCount): bool
{
$max = $this->maxBranches($organization);
return $max === null || $currentCount < $max;
}
public function proPriceMinor(): int
{
return (int) config('care.plans.pro.price_minor', 15000);
}
}
+189
View File
@@ -0,0 +1,189 @@
<?php
namespace App\Services\Care;
use App\Models\Consultation;
use App\Models\Prescription;
use App\Models\PrescriptionItem;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use InvalidArgumentException;
class PrescriptionService
{
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
{
return Prescription::owned($ownerRef)->where('organization_id', $organizationId);
}
/**
* @param array<string, mixed> $filters
*/
public function list(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
{
$query = $this->queryForOrganization($ownerRef, $organizationId)
->with(['patient', 'practitioner', 'items'])
->orderByDesc('created_at');
if ($status = $filters['status'] ?? null) {
$query->where('status', $status);
}
if ($patientId = $filters['patient_id'] ?? null) {
$query->where('patient_id', $patientId);
}
if ($branchId !== null) {
$query->whereHas('visit', fn (Builder $q) => $q->where('branch_id', $branchId));
}
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
}
/**
* @return Collection<int, Prescription>
*/
public function pharmacyQueue(string $ownerRef, int $organizationId): Collection
{
return $this->queryForOrganization($ownerRef, $organizationId)
->where('status', Prescription::STATUS_ACTIVE)
->with(['patient', 'practitioner', 'items', 'visit.branch'])
->orderBy('created_at')
->get();
}
/**
* @param array<string, mixed> $data
*/
public function createFromConsultation(
Consultation $consultation,
string $ownerRef,
array $data,
?string $actorRef = null,
): Prescription {
$consultation->loadMissing('visit');
$status = ($data['activate'] ?? true) ? Prescription::STATUS_ACTIVE : Prescription::STATUS_DRAFT;
$prescription = Prescription::create([
'owner_ref' => $ownerRef,
'organization_id' => $consultation->visit->organization_id,
'visit_id' => $consultation->visit_id,
'consultation_id' => $consultation->id,
'patient_id' => $consultation->patient_id,
'practitioner_id' => $data['practitioner_id'] ?? $consultation->practitioner_id,
'status' => $status,
'notes' => $data['notes'] ?? null,
'prescribed_by' => $actorRef,
]);
$this->syncItems($prescription, $ownerRef, $data['items'] ?? []);
AuditLogger::record(
$ownerRef,
'prescription.created',
$consultation->visit->organization_id,
$actorRef,
Prescription::class,
$prescription->id,
);
return $prescription->fresh(['items', 'patient', 'practitioner']);
}
/**
* @param array<string, mixed> $data
*/
public function update(Prescription $prescription, string $ownerRef, array $data, ?string $actorRef = null): Prescription
{
if ($prescription->status !== Prescription::STATUS_DRAFT) {
throw new InvalidArgumentException('Only draft prescriptions can be edited.');
}
$prescription->update([
'practitioner_id' => $data['practitioner_id'] ?? $prescription->practitioner_id,
'notes' => $data['notes'] ?? $prescription->notes,
]);
if (array_key_exists('items', $data)) {
$this->syncItems($prescription, $ownerRef, $data['items']);
}
AuditLogger::record($ownerRef, 'prescription.updated', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
return $prescription->fresh(['items', 'patient', 'practitioner']);
}
public function activate(Prescription $prescription, string $ownerRef, ?string $actorRef = null): Prescription
{
$this->assertStatus($prescription, Prescription::STATUS_DRAFT);
$prescription->update(['status' => Prescription::STATUS_ACTIVE]);
AuditLogger::record($ownerRef, 'prescription.activated', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
return $prescription->fresh(['items', 'patient']);
}
public function dispense(Prescription $prescription, string $ownerRef, ?string $actorRef = null): Prescription
{
$this->assertStatus($prescription, Prescription::STATUS_ACTIVE);
$prescription->update([
'status' => Prescription::STATUS_DISPENSED,
'dispensed_at' => now(),
'dispensed_by' => $actorRef,
]);
AuditLogger::record($ownerRef, 'prescription.dispensed', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
return $prescription->fresh(['items', 'patient']);
}
public function cancel(Prescription $prescription, string $ownerRef, ?string $actorRef = null): Prescription
{
if (in_array($prescription->status, [Prescription::STATUS_DISPENSED, Prescription::STATUS_CANCELLED], true)) {
throw new InvalidArgumentException('Prescription cannot be cancelled.');
}
$prescription->update(['status' => Prescription::STATUS_CANCELLED]);
AuditLogger::record($ownerRef, 'prescription.cancelled', $prescription->organization_id, $actorRef, Prescription::class, $prescription->id);
return $prescription->fresh(['items', 'patient']);
}
/**
* @param array<int, array<string, mixed>> $items
*/
protected function syncItems(Prescription $prescription, string $ownerRef, array $items): void
{
$prescription->items()->delete();
foreach ($items as $index => $row) {
if (empty($row['name'])) {
continue;
}
PrescriptionItem::create([
'owner_ref' => $ownerRef,
'prescription_id' => $prescription->id,
'is_procedure' => (bool) ($row['is_procedure'] ?? false),
'name' => $row['name'],
'dosage' => $row['dosage'] ?? null,
'frequency' => $row['frequency'] ?? null,
'duration' => $row['duration'] ?? null,
'route' => $row['route'] ?? null,
'quantity' => $row['quantity'] ?? null,
'instructions' => $row['instructions'] ?? null,
'sort_order' => $index,
]);
}
}
protected function assertStatus(Prescription $prescription, string ...$allowed): void
{
if (! in_array($prescription->status, $allowed, true)) {
throw new InvalidArgumentException("Cannot transition from {$prescription->status}.");
}
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
namespace App\Services\Care;
use App\Models\Appointment;
use App\Models\Bill;
use App\Models\Diagnosis;
use App\Models\InvestigationRequest;
use App\Models\Patient;
use App\Models\Payment;
use App\Models\Visit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class ReportService
{
/**
* @return array<string, mixed>
*/
public function dashboardStats(string $ownerRef, int $organizationId, ?int $branchId = null): array
{
$today = now()->startOfDay();
$patientsToday = Patient::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereDate('created_at', $today)
->count();
$appointmentsToday = Appointment::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereDate('scheduled_at', $today)
->count();
$openBills = Bill::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereIn('status', [Bill::STATUS_OPEN, Bill::STATUS_PARTIAL])
->count();
$revenueToday = Payment::owned($ownerRef)
->whereHas('bill', function (Builder $q) use ($organizationId, $branchId) {
$q->where('organization_id', $organizationId);
if ($branchId) {
$q->where('branch_id', $branchId);
}
})
->whereDate('paid_at', $today)
->sum('amount_minor');
$pendingLab = InvestigationRequest::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereIn('status', InvestigationRequest::activeStatuses())
->count();
return [
'patients_today' => $patientsToday,
'appointments_today' => $appointmentsToday,
'open_bills' => $openBills,
'revenue_today_minor' => (int) $revenueToday,
'pending_lab' => $pendingLab,
];
}
/**
* @return array<string, mixed>
*/
public function patientsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$newPatients = Patient::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('created_at', [$from, $to])
->count();
$returningPatients = Visit::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('checked_in_at', [$from, $to])
->distinct('patient_id')
->count('patient_id');
$visits = Visit::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('checked_in_at', [$from, $to])
->count();
return [
'new_patients' => $newPatients,
'returning_patients' => $returningPatients,
'total_visits' => $visits,
];
}
/**
* @return array<string, mixed>
*/
public function appointmentsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$base = Appointment::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('scheduled_at', [$from, $to]);
return [
'total' => (clone $base)->count(),
'completed' => (clone $base)->where('status', Appointment::STATUS_COMPLETED)->count(),
'cancelled' => (clone $base)->where('status', Appointment::STATUS_CANCELLED)->count(),
'no_show' => (clone $base)->where('status', Appointment::STATUS_NO_SHOW)->count(),
];
}
/**
* @return array<string, mixed>
*/
public function laboratoryReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$base = InvestigationRequest::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('created_at', [$from, $to]);
$completed = (clone $base)
->whereIn('status', [InvestigationRequest::STATUS_COMPLETED, InvestigationRequest::STATUS_DELIVERED])
->get();
$turnaroundHours = $completed
->filter(fn ($r) => $r->completed_at && $r->created_at)
->map(fn ($r) => $r->created_at->diffInHours($r->completed_at))
->avg();
return [
'requested' => (clone $base)->count(),
'completed' => $completed->count(),
'pending' => (clone $base)->whereIn('status', InvestigationRequest::activeStatuses())->count(),
'avg_turnaround_hours' => $turnaroundHours ? round($turnaroundHours, 1) : null,
];
}
/**
* @return array<string, mixed>
*/
public function financeReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$billed = Bill::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('created_at', [$from, $to])
->where('status', '!=', Bill::STATUS_VOID);
$payments = Payment::owned($ownerRef)
->whereHas('bill', function (Builder $q) use ($organizationId, $branchId) {
$q->where('organization_id', $organizationId);
if ($branchId) {
$q->where('branch_id', $branchId);
}
})
->whereBetween('paid_at', [$from, $to]);
return [
'invoiced_minor' => (int) (clone $billed)->sum('total_minor'),
'collected_minor' => (int) (clone $payments)->sum('amount_minor'),
'outstanding_minor' => (int) Bill::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereIn('status', [Bill::STATUS_OPEN, Bill::STATUS_PARTIAL])
->sum('balance_minor'),
'invoice_count' => (clone $billed)->count(),
];
}
/**
* @return Collection<int, object>
*/
public function clinicalReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to): Collection
{
return Diagnosis::owned($ownerRef)
->whereHas('consultation.visit', function (Builder $q) use ($organizationId, $from, $to) {
$q->where('organization_id', $organizationId)
->whereBetween('checked_in_at', [$from, $to]);
})
->select('description', DB::raw('count(*) as total'))
->groupBy('description')
->orderByDesc('total')
->limit(20)
->get();
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Services\Care;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Visit;
class VisitService
{
public function checkIn(
Organization $organization,
string $ownerRef,
Patient $patient,
int $branchId,
?string $actorRef = null,
): Visit {
$visit = Visit::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branchId,
'patient_id' => $patient->id,
'status' => Visit::STATUS_OPEN,
'checked_in_at' => now(),
'checked_in_by' => $actorRef,
]);
AuditLogger::record($ownerRef, 'visit.checked_in', $organization->id, $actorRef, Visit::class, $visit->id);
return $visit;
}
public function complete(Visit $visit, string $ownerRef, ?string $actorRef = null): Visit
{
$visit->update([
'status' => Visit::STATUS_COMPLETED,
'completed_at' => now(),
]);
AuditLogger::record($ownerRef, 'visit.completed', $visit->organization_id, $actorRef, Visit::class, $visit->id);
return $visit;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Services\Comms;
use App\Mail\ContactMessageMail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
/**
* Outbound email to contacts. Uses the app's configured mailer (set MAIL_* to
* a Ladill Bird SMTP credential in production so mail leaves as the account's
* sender). Failures are swallowed + logged; the caller decides what to record.
*/
class EmailService
{
public function send(string $to, string $subject, string $body, ?string $fromName = null, ?string $replyTo = null): bool
{
if (! filter_var($to, FILTER_VALIDATE_EMAIL)) {
return false;
}
try {
Mail::to($to)->send(new ContactMessageMail($subject, $body, $fromName, $replyTo));
return true;
} catch (\Throwable $e) {
Log::warning('CRM email send failed', ['to' => $to, 'error' => $e->getMessage()]);
return false;
}
}
}

Some files were not shown because too many files have changed in this diff Show More