chore: add spec-kit and standardize signatures
- Added GitHub spec-kit for development workflow - Standardized file signatures to Descomplicar® format - Updated development configuration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
48
.editorconfig
Normal file
48
.editorconfig
Normal file
@@ -0,0 +1,48 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# All files
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
# JSON, YAML, Markdown files
|
||||
[*.{json,yml,yaml,md}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# PHP files - WordPress standards
|
||||
[*.php]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
max_line_length = 120
|
||||
|
||||
# JavaScript and CSS files
|
||||
[*.{js,css,scss,sass}]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
# Configuration files
|
||||
[*.{xml,ini,conf}]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
# Shell scripts
|
||||
[*.{sh,bash}]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
# Make files
|
||||
[{Makefile,makefile,*.mk}]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
# Specific WordPress files
|
||||
[{*.txt,LICENSE,README}]
|
||||
trim_trailing_whitespace = false
|
||||
43
CLAUDE.md
Normal file
43
CLAUDE.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# care-api Development Guidelines
|
||||
|
||||
Auto-generated from all feature plans. Last updated: 2025-09-12
|
||||
|
||||
## Active Technologies
|
||||
- PHP 8.1+ WordPress plugin development (001-care-api-sistema)
|
||||
- WordPress REST API framework with JWT authentication
|
||||
- KiviCare 35-table database schema integration
|
||||
- PHPUnit testing with WordPress testing framework
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
src/
|
||||
├── models/ # KiviCare entity models
|
||||
├── services/ # Business logic services
|
||||
├── endpoints/ # REST API endpoint handlers
|
||||
├── auth/ # JWT authentication service
|
||||
└── utils/ # Helper utilities
|
||||
|
||||
tests/
|
||||
├── contract/ # API contract tests
|
||||
├── integration/ # Database integration tests
|
||||
└── unit/ # Unit tests
|
||||
```
|
||||
|
||||
## Commands
|
||||
# WordPress/PHP specific commands
|
||||
wp plugin activate kivicare-api
|
||||
wp config set WP_DEBUG true
|
||||
vendor/bin/phpunit tests/
|
||||
wp db query "SELECT..."
|
||||
|
||||
## Code Style
|
||||
- WordPress coding standards (WPCS)
|
||||
- PSR-4 autoloading for classes
|
||||
- WordPress hooks and filters for extensibility
|
||||
- Prepared SQL statements for security
|
||||
|
||||
## Recent Changes
|
||||
- 001-care-api-sistema: Added REST API for KiviCare healthcare management system
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
562
SPEC_CARE_API.md
Normal file
562
SPEC_CARE_API.md
Normal file
@@ -0,0 +1,562 @@
|
||||
# KiviCare API - Especificações Técnicas
|
||||
|
||||
**Projeto**: KiviCare MCP Integration
|
||||
**Versão**: 1.0.0
|
||||
**Data**: 2025-01-12
|
||||
**Autor**: Descomplicar® Crescimento Digital
|
||||
**URL**: https://descomplicar.pt
|
||||
|
||||
---
|
||||
|
||||
## 🎯 OVERVIEW DO SISTEMA
|
||||
|
||||
### Descrição
|
||||
KiviCare é um sistema completo de gestão de clínicas médicas implementado como plugin WordPress. O sistema gere pacientes, médicos, consultas, prescrições, faturas e relatórios médicos através de uma estrutura de base de dados com 35 tabelas especializadas.
|
||||
|
||||
### Funcionalidades Core
|
||||
- **Gestão de Pacientes**: Registo, histórico médico, consultas
|
||||
- **Gestão de Médicos**: Perfis, horários, especializações
|
||||
- **Agendamento**: Consultas, lembretes, integrações (Zoom/Google Meet)
|
||||
- **Consultas Médicas**: Encounters, prescrições, relatórios
|
||||
- **Faturação**: Bills, pagamentos, serviços
|
||||
- **Administração**: Clínicas, utilizadores, configurações
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ ARQUITETURA DA BASE DE DADOS
|
||||
|
||||
### Tabelas Principais (Core)
|
||||
|
||||
#### 🏥 **wp_kc_clinics** - Gestão de Clínicas
|
||||
```sql
|
||||
- id (bigint, PK)
|
||||
- name (varchar 191)
|
||||
- email (varchar 191)
|
||||
- telephone_no (varchar 191)
|
||||
- specialties (longtext)
|
||||
- address (text)
|
||||
- city, state, country (varchar 191)
|
||||
- postal_code (varchar 191)
|
||||
- status (tinyint)
|
||||
- clinic_admin_id (bigint)
|
||||
- clinic_logo, profile_image (bigint)
|
||||
- extra (longtext)
|
||||
```
|
||||
|
||||
#### 👨⚕️ **wp_kc_appointments** - Agendamentos
|
||||
```sql
|
||||
- id (bigint, PK)
|
||||
- appointment_start_date (date)
|
||||
- appointment_start_time (time)
|
||||
- appointment_end_date (date)
|
||||
- appointment_end_time (time)
|
||||
- visit_type (varchar 191)
|
||||
- clinic_id (bigint) → FK wp_kc_clinics
|
||||
- doctor_id (bigint) → FK wp_users
|
||||
- patient_id (bigint) → FK wp_users
|
||||
- description (text)
|
||||
- status (tinyint)
|
||||
- created_at (datetime)
|
||||
- appointment_report (longtext)
|
||||
```
|
||||
|
||||
#### 🩺 **wp_kc_patient_encounters** - Consultas Médicas
|
||||
```sql
|
||||
- id (bigint, PK)
|
||||
- encounter_date (date)
|
||||
- clinic_id (bigint) → FK wp_kc_clinics
|
||||
- doctor_id (bigint) → FK wp_users
|
||||
- patient_id (bigint) → FK wp_users
|
||||
- appointment_id (bigint) → FK wp_kc_appointments
|
||||
- description (text)
|
||||
- status (tinyint)
|
||||
- added_by (bigint)
|
||||
- created_at (datetime)
|
||||
- template_id (bigint)
|
||||
```
|
||||
|
||||
#### 💊 **wp_kc_prescription** - Prescrições
|
||||
```sql
|
||||
- id (bigint, PK)
|
||||
- encounter_id (bigint) → FK wp_kc_patient_encounters
|
||||
- patient_id (bigint) → FK wp_users
|
||||
- name (text) - Nome do medicamento
|
||||
- frequency (varchar 199)
|
||||
- duration (varchar 199)
|
||||
- instruction (text)
|
||||
- added_by (bigint)
|
||||
- created_at (datetime)
|
||||
- is_from_template (tinyint)
|
||||
```
|
||||
|
||||
#### 💰 **wp_kc_bills** - Faturação
|
||||
```sql
|
||||
- id (bigint, PK)
|
||||
- encounter_id (bigint) → FK wp_kc_patient_encounters
|
||||
- appointment_id (bigint) → FK wp_kc_appointments
|
||||
- title (varchar 191)
|
||||
- total_amount (varchar 50)
|
||||
- discount (varchar 50)
|
||||
- actual_amount (varchar 50)
|
||||
- status (bigint)
|
||||
- payment_status (varchar 10)
|
||||
- created_at (datetime)
|
||||
- clinic_id (bigint)
|
||||
```
|
||||
|
||||
### Tabelas de Relacionamentos
|
||||
|
||||
#### 🔗 **wp_kc_doctor_clinic_mappings**
|
||||
```sql
|
||||
- doctor_id → wp_users
|
||||
- clinic_id → wp_kc_clinics
|
||||
```
|
||||
|
||||
#### 🔗 **wp_kc_patient_clinic_mappings**
|
||||
```sql
|
||||
- patient_id → wp_users
|
||||
- clinic_id → wp_kc_clinics
|
||||
```
|
||||
|
||||
#### 🔗 **wp_kc_appointment_service_mapping**
|
||||
```sql
|
||||
- appointment_id → wp_kc_appointments
|
||||
- service_id → wp_kc_services
|
||||
```
|
||||
|
||||
### Tabelas Especializadas
|
||||
|
||||
#### ⚕️ **wp_kc_services** - Serviços Médicos
|
||||
```sql
|
||||
- id, type, name, price, status, created_at
|
||||
```
|
||||
|
||||
#### 📋 **wp_kc_medical_history** - Histórico Médico
|
||||
```sql
|
||||
- encounter_id, patient_id, type, title, added_by
|
||||
```
|
||||
|
||||
#### 🔧 **wp_kc_custom_fields** - Campos Personalizados
|
||||
```sql
|
||||
- name, type, status, created_at
|
||||
```
|
||||
|
||||
#### 📞 **wp_kc_appointment_reminder_mapping** - Lembretes
|
||||
```sql
|
||||
- appointment_id, status, created_at
|
||||
```
|
||||
|
||||
#### 🎥 **Integrações Video**
|
||||
- `wp_kc_appointment_zoom_mappings`
|
||||
- `wp_kc_appointment_google_meet_mappings`
|
||||
- `wp_kc_gcal_appointment_mapping`
|
||||
|
||||
---
|
||||
|
||||
## 🎛️ ENDPOINTS API PROPOSTOS
|
||||
|
||||
### **Authentication**
|
||||
```http
|
||||
POST /wp-json/kivicare/v1/auth/login
|
||||
POST /wp-json/kivicare/v1/auth/refresh
|
||||
POST /wp-json/kivicare/v1/auth/logout
|
||||
```
|
||||
|
||||
### **Clínicas**
|
||||
```http
|
||||
GET /wp-json/kivicare/v1/clinics
|
||||
POST /wp-json/kivicare/v1/clinics
|
||||
GET /wp-json/kivicare/v1/clinics/{id}
|
||||
PUT /wp-json/kivicare/v1/clinics/{id}
|
||||
DELETE /wp-json/kivicare/v1/clinics/{id}
|
||||
```
|
||||
|
||||
### **Pacientes**
|
||||
```http
|
||||
GET /wp-json/kivicare/v1/patients
|
||||
POST /wp-json/kivicare/v1/patients
|
||||
GET /wp-json/kivicare/v1/patients/{id}
|
||||
PUT /wp-json/kivicare/v1/patients/{id}
|
||||
GET /wp-json/kivicare/v1/patients/{id}/history
|
||||
GET /wp-json/kivicare/v1/patients/{id}/encounters
|
||||
GET /wp-json/kivicare/v1/patients/{id}/prescriptions
|
||||
```
|
||||
|
||||
### **Médicos**
|
||||
```http
|
||||
GET /wp-json/kivicare/v1/doctors
|
||||
GET /wp-json/kivicare/v1/doctors/{id}
|
||||
GET /wp-json/kivicare/v1/doctors/{id}/schedule
|
||||
GET /wp-json/kivicare/v1/doctors/{id}/appointments
|
||||
PUT /wp-json/kivicare/v1/doctors/{id}/schedule
|
||||
```
|
||||
|
||||
### **Agendamentos**
|
||||
```http
|
||||
GET /wp-json/kivicare/v1/appointments
|
||||
POST /wp-json/kivicare/v1/appointments
|
||||
GET /wp-json/kivicare/v1/appointments/{id}
|
||||
PUT /wp-json/kivicare/v1/appointments/{id}
|
||||
DELETE /wp-json/kivicare/v1/appointments/{id}
|
||||
GET /wp-json/kivicare/v1/appointments/available-slots
|
||||
```
|
||||
|
||||
### **Consultas Médicas**
|
||||
```http
|
||||
GET /wp-json/kivicare/v1/encounters
|
||||
POST /wp-json/kivicare/v1/encounters
|
||||
GET /wp-json/kivicare/v1/encounters/{id}
|
||||
PUT /wp-json/kivicare/v1/encounters/{id}
|
||||
GET /wp-json/kivicare/v1/encounters/{id}/prescriptions
|
||||
POST /wp-json/kivicare/v1/encounters/{id}/prescriptions
|
||||
```
|
||||
|
||||
### **Faturação**
|
||||
```http
|
||||
GET /wp-json/kivicare/v1/bills
|
||||
POST /wp-json/kivicare/v1/bills
|
||||
GET /wp-json/kivicare/v1/bills/{id}
|
||||
PUT /wp-json/kivicare/v1/bills/{id}
|
||||
POST /wp-json/kivicare/v1/bills/{id}/payment
|
||||
```
|
||||
|
||||
### **Serviços**
|
||||
```http
|
||||
GET /wp-json/kivicare/v1/services
|
||||
POST /wp-json/kivicare/v1/services
|
||||
PUT /wp-json/kivicare/v1/services/{id}
|
||||
DELETE /wp-json/kivicare/v1/services/{id}
|
||||
```
|
||||
|
||||
### **Relatórios**
|
||||
```http
|
||||
GET /wp-json/kivicare/v1/reports/appointments
|
||||
GET /wp-json/kivicare/v1/reports/revenue
|
||||
GET /wp-json/kivicare/v1/reports/patients
|
||||
GET /wp-json/kivicare/v1/reports/doctors
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 SEGURANÇA E AUTENTICAÇÃO
|
||||
|
||||
### WordPress Authentication
|
||||
- **JWT Token** baseado em wp_users
|
||||
- **Role-based access**: administrator, doctor, patient, receptionist
|
||||
- **Capability checks** por endpoint
|
||||
- **Nonce verification** para operações críticas
|
||||
|
||||
### Permissions Matrix
|
||||
```php
|
||||
'administrator' => ['all_operations'],
|
||||
'doctor' => ['read_own_patients', 'create_encounters', 'prescriptions'],
|
||||
'patient' => ['read_own_data', 'book_appointments'],
|
||||
'receptionist' => ['manage_appointments', 'basic_patient_data']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 MODELOS DE DADOS
|
||||
|
||||
### Patient Profile
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"name": "João Silva",
|
||||
"email": "joao@email.com",
|
||||
"phone": "+351912345678",
|
||||
"birth_date": "1985-05-15",
|
||||
"gender": "M",
|
||||
"address": {
|
||||
"street": "Rua da Saúde, 123",
|
||||
"city": "Lisboa",
|
||||
"postal_code": "1000-001"
|
||||
},
|
||||
"medical_history": [
|
||||
{
|
||||
"type": "chronic_disease",
|
||||
"title": "Diabetes Tipo 2",
|
||||
"date": "2020-01-15"
|
||||
}
|
||||
],
|
||||
"last_visit": "2024-12-15",
|
||||
"clinic_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Appointment
|
||||
```json
|
||||
{
|
||||
"id": 456,
|
||||
"start_date": "2024-12-20",
|
||||
"start_time": "14:30:00",
|
||||
"end_date": "2024-12-20",
|
||||
"end_time": "15:00:00",
|
||||
"visit_type": "consultation",
|
||||
"status": "scheduled",
|
||||
"patient": {
|
||||
"id": 123,
|
||||
"name": "João Silva"
|
||||
},
|
||||
"doctor": {
|
||||
"id": 789,
|
||||
"name": "Dr. Maria Santos"
|
||||
},
|
||||
"clinic": {
|
||||
"id": 1,
|
||||
"name": "Clínica Central"
|
||||
},
|
||||
"services": ["consultation_general"],
|
||||
"notes": "Consulta de rotina"
|
||||
}
|
||||
```
|
||||
|
||||
### Medical Encounter
|
||||
```json
|
||||
{
|
||||
"id": 789,
|
||||
"encounter_date": "2024-12-20",
|
||||
"patient_id": 123,
|
||||
"doctor_id": 789,
|
||||
"appointment_id": 456,
|
||||
"diagnosis": "Gripe comum",
|
||||
"symptoms": ["febre", "tosse", "dor de garganta"],
|
||||
"prescriptions": [
|
||||
{
|
||||
"medication": "Paracetamol 500mg",
|
||||
"frequency": "8/8h",
|
||||
"duration": "7 dias",
|
||||
"instructions": "Tomar com água após refeições"
|
||||
}
|
||||
],
|
||||
"next_visit": "2024-12-27",
|
||||
"status": "completed"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 IMPLEMENTAÇÃO TÉCNICA
|
||||
|
||||
### Plugin WordPress Structure
|
||||
```
|
||||
kivicare-api/
|
||||
├── kivicare-api.php (main plugin file)
|
||||
├── includes/
|
||||
│ ├── class-kivicare-api.php
|
||||
│ ├── class-rest-controller.php
|
||||
│ └── endpoints/
|
||||
│ ├── class-clinics-endpoint.php
|
||||
│ ├── class-patients-endpoint.php
|
||||
│ ├── class-appointments-endpoint.php
|
||||
│ ├── class-encounters-endpoint.php
|
||||
│ └── class-bills-endpoint.php
|
||||
├── models/
|
||||
│ ├── class-clinic.php
|
||||
│ ├── class-patient.php
|
||||
│ ├── class-appointment.php
|
||||
│ └── class-encounter.php
|
||||
└── utils/
|
||||
├── class-auth-helper.php
|
||||
├── class-data-validator.php
|
||||
└── class-db-helper.php
|
||||
```
|
||||
|
||||
### Core Classes
|
||||
```php
|
||||
class KiviCare_API_Clinics_Endpoint extends WP_REST_Controller {
|
||||
protected $namespace = 'kivicare/v1';
|
||||
protected $rest_base = 'clinics';
|
||||
|
||||
public function register_routes() {
|
||||
register_rest_route($this->namespace, '/' . $this->rest_base, [
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'get_items'],
|
||||
'permission_callback' => [$this, 'get_items_permissions_check']
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Database Queries Optimization
|
||||
```php
|
||||
class KiviCare_DB_Helper {
|
||||
public static function get_patient_full_data($patient_id) {
|
||||
global $wpdb;
|
||||
|
||||
$query = "
|
||||
SELECT p.*, pm.clinic_id, c.name as clinic_name
|
||||
FROM {$wpdb->users} p
|
||||
LEFT JOIN {$wpdb->prefix}kc_patient_clinic_mappings pm ON p.ID = pm.patient_id
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON pm.clinic_id = c.id
|
||||
WHERE p.ID = %d
|
||||
";
|
||||
|
||||
return $wpdb->get_row($wpdb->prepare($query, $patient_id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 TESTES E VALIDAÇÃO
|
||||
|
||||
### Unit Tests
|
||||
```php
|
||||
class Test_KiviCare_Appointments extends WP_UnitTestCase {
|
||||
public function test_create_appointment() {
|
||||
$appointment_data = [
|
||||
'patient_id' => 1,
|
||||
'doctor_id' => 2,
|
||||
'clinic_id' => 1,
|
||||
'start_date' => '2024-12-20',
|
||||
'start_time' => '14:30:00'
|
||||
];
|
||||
|
||||
$result = KiviCare_Appointments::create($appointment_data);
|
||||
$this->assertIsInt($result);
|
||||
$this->assertGreaterThan(0, $result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Integration Tests
|
||||
```bash
|
||||
# Test Authentication
|
||||
curl -X POST http://site.local/wp-json/kivicare/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"doctor","password":"password"}'
|
||||
|
||||
# Test Get Appointments
|
||||
curl -X GET http://site.local/wp-json/kivicare/v1/appointments \
|
||||
-H "Authorization: Bearer TOKEN"
|
||||
|
||||
# Test Create Appointment
|
||||
curl -X POST http://site.local/wp-json/kivicare/v1/appointments \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer TOKEN" \
|
||||
-d '{"patient_id":1,"doctor_id":2,"start_date":"2024-12-20"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 PERFORMANCE E ESCALABILIDADE
|
||||
|
||||
### Database Indexes
|
||||
```sql
|
||||
ALTER TABLE wp_kc_appointments
|
||||
ADD INDEX idx_doctor_date (doctor_id, appointment_start_date);
|
||||
|
||||
ALTER TABLE wp_kc_patient_encounters
|
||||
ADD INDEX idx_patient_date (patient_id, encounter_date);
|
||||
|
||||
ALTER TABLE wp_kc_bills
|
||||
ADD INDEX idx_clinic_status (clinic_id, status);
|
||||
```
|
||||
|
||||
### Caching Strategy
|
||||
```php
|
||||
class KiviCare_Cache {
|
||||
public static function get_patient_encounters($patient_id) {
|
||||
$cache_key = "patient_encounters_{$patient_id}";
|
||||
$encounters = wp_cache_get($cache_key, 'kivicare');
|
||||
|
||||
if (false === $encounters) {
|
||||
$encounters = self::fetch_from_db($patient_id);
|
||||
wp_cache_set($cache_key, $encounters, 'kivicare', 3600);
|
||||
}
|
||||
|
||||
return $encounters;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 CONFIGURAÇÃO E DEPLOYMENT
|
||||
|
||||
### Plugin Activation
|
||||
```php
|
||||
register_activation_hook(__FILE__, 'kivicare_api_activate');
|
||||
|
||||
function kivicare_api_activate() {
|
||||
// Verificar se KiviCare plugin está ativo
|
||||
if (!is_plugin_active('kivicare-clinic-&-patient-management-system/kivicare-clinic-&-patient-management-system.php')) {
|
||||
wp_die('KiviCare Plugin é necessário para ativar a API.');
|
||||
}
|
||||
|
||||
// Criar capabilities
|
||||
$role = get_role('doctor');
|
||||
$role->add_cap('manage_kivicare_api');
|
||||
|
||||
// Flush rewrite rules
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
```php
|
||||
// wp-config.php additions
|
||||
define('KIVICARE_API_VERSION', '1.0.0');
|
||||
define('KIVICARE_API_DEBUG', false);
|
||||
define('KIVICARE_API_CACHE_TTL', 3600);
|
||||
define('KIVICARE_JWT_SECRET', 'your-secret-key');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 CHECKLIST DE IMPLEMENTAÇÃO
|
||||
|
||||
### Fase 1 - Core API (Semana 1-2)
|
||||
- [ ] Plugin base e estrutura
|
||||
- [ ] Authentication endpoints
|
||||
- [ ] Clinics CRUD endpoints
|
||||
- [ ] Patients CRUD endpoints
|
||||
- [ ] Basic error handling
|
||||
|
||||
### Fase 2 - Appointments & Encounters (Semana 3-4)
|
||||
- [ ] Appointments CRUD endpoints
|
||||
- [ ] Slot availability logic
|
||||
- [ ] Patient encounters endpoints
|
||||
- [ ] Prescription management
|
||||
|
||||
### Fase 3 - Billing & Advanced (Semana 5-6)
|
||||
- [ ] Bills and payments endpoints
|
||||
- [ ] Services management
|
||||
- [ ] Reporting endpoints
|
||||
- [ ] Advanced filtering and search
|
||||
|
||||
### Fase 4 - Testing & Documentation (Semana 7-8)
|
||||
- [ ] Unit tests completos
|
||||
- [ ] Integration tests
|
||||
- [ ] API documentation
|
||||
- [ ] Performance optimization
|
||||
|
||||
---
|
||||
|
||||
## 🔮 ROADMAP FUTURO
|
||||
|
||||
### v1.1 - Integrações
|
||||
- Sincronização com calendários externos
|
||||
- Integração com sistemas de pagamento
|
||||
- Notificações push/email automatizadas
|
||||
|
||||
### v1.2 - Analytics
|
||||
- Dashboard de métricas médicas
|
||||
- Relatórios financeiros avançados
|
||||
- Business intelligence integrado
|
||||
|
||||
### v1.3 - Mobile
|
||||
- App mobile nativo
|
||||
- Offline synchronization
|
||||
- Patient portal app
|
||||
|
||||
---
|
||||
|
||||
**Assinatura**: Descomplicar® Crescimento Digital
|
||||
**URL**: https://descomplicar.pt
|
||||
**Contacto**: Desenvolvimento técnico especializado
|
||||
|
||||
---
|
||||
|
||||
*Especificações técnicas detalhadas para implementação de API KiviCare com arquitetura WordPress robusta e escalável.*
|
||||
87
composer.json
Normal file
87
composer.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "descomplicar/kivicare-api",
|
||||
"description": "REST API extension for KiviCare WordPress plugin - Healthcare management system",
|
||||
"type": "wordpress-plugin",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Descomplicar® Crescimento Digital",
|
||||
"email": "dev@descomplicar.pt",
|
||||
"homepage": "https://descomplicar.pt"
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"wordpress",
|
||||
"plugin",
|
||||
"healthcare",
|
||||
"api",
|
||||
"kivicare",
|
||||
"medical",
|
||||
"clinic"
|
||||
],
|
||||
"homepage": "https://descomplicar.pt",
|
||||
"support": {
|
||||
"issues": "https://github.com/descomplicar/kivicare-api/issues",
|
||||
"source": "https://github.com/descomplicar/kivicare-api"
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"firebase/php-jwt": "^6.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"wp-coding-standards/wpcs": "^3.0",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"wp-cli/wp-cli": "^2.8",
|
||||
"wp-cli/wp-cli-bundle": "^2.8",
|
||||
"yoast/phpunit-polyfills": "^2.0"
|
||||
},
|
||||
"suggest": {
|
||||
"wp-cli/wp-cli": "Required for WordPress testing environment setup"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"KiviCare_API\\": "src/"
|
||||
},
|
||||
"files": [
|
||||
"src/kivicare-api.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"KiviCare_API\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"install-codestandards": [
|
||||
"Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin::run"
|
||||
],
|
||||
"phpcs": "phpcs",
|
||||
"phpcbf": "phpcbf",
|
||||
"phpunit": "phpunit",
|
||||
"test": [
|
||||
"@phpcs",
|
||||
"@phpunit"
|
||||
],
|
||||
"post-install-cmd": [
|
||||
"@install-codestandards"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@install-codestandards"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"wordpress-install-dir": "vendor/wordpress/wordpress",
|
||||
"wordpress-plugin-slug": "kivicare-api"
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
BIN
composer.phar
Normal file
BIN
composer.phar
Normal file
Binary file not shown.
489895
dump_sintri_wp66181_20250911225756.sql
Normal file
489895
dump_sintri_wp66181_20250911225756.sql
Normal file
File diff suppressed because one or more lines are too long
93
phpcs.xml
Normal file
93
phpcs.xml
Normal file
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0"?>
|
||||
<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" name="KiviCare API" xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/squizlabs/PHP_CodeSniffer/master/phpcs.xsd">
|
||||
|
||||
<description>Custom WordPress Coding Standards for KiviCare API plugin.</description>
|
||||
|
||||
<!-- What to scan -->
|
||||
<file>src</file>
|
||||
<file>tests</file>
|
||||
<exclude-pattern>/vendor/</exclude-pattern>
|
||||
<exclude-pattern>/node_modules/</exclude-pattern>
|
||||
<exclude-pattern>*.min.js</exclude-pattern>
|
||||
<exclude-pattern>*.min.css</exclude-pattern>
|
||||
|
||||
<!-- How to scan -->
|
||||
<!-- Usage instructions: https://github.com/squizlabs/PHP_CodeSniffer/wiki/Usage -->
|
||||
<!-- Annotated ruleset: https://github.com/squizlabs/PHP_CodeSniffer/wiki/Annotated-ruleset.xml -->
|
||||
<arg value="sp"/> <!-- Show sniff and progress -->
|
||||
<arg name="basepath" value="./"/><!-- Strip the file paths down to the relevant bit -->
|
||||
<arg name="colors"/>
|
||||
<arg name="extensions" value="php"/>
|
||||
<arg name="parallel" value="8"/><!-- Enables parallel processing when available for faster results. -->
|
||||
|
||||
<!-- Rules: Check PHP version compatibility -->
|
||||
<!-- https://github.com/PHPCompatibility/PHPCompatibility#sniffing-your-code-for-compatibility-with-specific-php-versions -->
|
||||
<config name="testVersion" value="8.1-"/>
|
||||
<!-- https://github.com/PHPCompatibility/PHPCompatibilityWP -->
|
||||
<rule ref="PHPCompatibilityWP"/>
|
||||
|
||||
<!-- Rules: WordPress Coding Standards -->
|
||||
<!-- https://github.com/WordPress-Coding-Standards/WordPress-Coding-Standards -->
|
||||
<!-- https://github.com/WordPress-Coding-Standards/WordPress-Coding-Standards/wiki/Customizable-sniff-properties -->
|
||||
<config name="minimum_supported_wp_version" value="6.0"/>
|
||||
|
||||
<rule ref="WordPress">
|
||||
<!-- This rule does not apply here since we are adhering to the PSR-4 standard -->
|
||||
<exclude name="WordPress.Files.FileName.InvalidClassFileName"/>
|
||||
<exclude name="WordPress.Files.FileName.NotHyphenatedLowercase"/>
|
||||
|
||||
<!-- Allow short array syntax -->
|
||||
<exclude name="Generic.Arrays.DisallowShortArraySyntax.Found"/>
|
||||
|
||||
<!-- Allow modern PHP syntax -->
|
||||
<exclude name="Squiz.PHP.DisallowMultipleAssignments.Found"/>
|
||||
</rule>
|
||||
|
||||
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
|
||||
<properties>
|
||||
<!-- Value: replace the function, class, and variable prefixes used. Separate multiple prefixes with a comma. -->
|
||||
<property name="prefixes" type="array" value="kivicare_api,KiviCare_API,KIVICARE_API"/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<rule ref="WordPress.WP.I18n">
|
||||
<properties>
|
||||
<!-- Value: replace the text domain used. -->
|
||||
<property name="text_domain" type="array" value="kivicare-api"/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<rule ref="WordPress.WhiteSpace.ControlStructureSpacing">
|
||||
<properties>
|
||||
<property name="blank_line_check" value="true"/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<!-- Rules: Allow WordPress VIP Go coding standards for better performance -->
|
||||
<!-- https://github.com/Automattic/VIP-Coding-Standards/ -->
|
||||
<rule ref="WordPress-VIP-Go">
|
||||
<!-- Allow direct database queries - we need them for KiviCare integration -->
|
||||
<exclude name="WordPress.DB.DirectDatabaseQuery.DirectQuery"/>
|
||||
<exclude name="WordPress.DB.DirectDatabaseQuery.NoCaching"/>
|
||||
|
||||
<!-- Allow file system writes for logging -->
|
||||
<exclude name="WordPress.WP.AlternativeFunctions.file_system_read_fopen"/>
|
||||
<exclude name="WordPress.WP.AlternativeFunctions.file_system_read_fwrite"/>
|
||||
</rule>
|
||||
|
||||
<!-- Custom rules for KiviCare API -->
|
||||
<rule ref="Generic.Commenting.DocComment"/>
|
||||
<rule ref="Squiz.Commenting.ClassComment"/>
|
||||
<rule ref="Squiz.Commenting.FunctionComment"/>
|
||||
<rule ref="Generic.Commenting.DocComment.MissingShort"/>
|
||||
|
||||
<!-- Security Rules -->
|
||||
<rule ref="WordPress.Security.EscapeOutput"/>
|
||||
<rule ref="WordPress.Security.NonceVerification"/>
|
||||
<rule ref="WordPress.Security.ValidatedSanitizedInput"/>
|
||||
|
||||
<!-- Database Rules -->
|
||||
<rule ref="WordPress.DB.PreparedSQL"/>
|
||||
<rule ref="WordPress.DB.SlowDBQuery"/>
|
||||
|
||||
</ruleset>
|
||||
83
phpunit.xml
Normal file
83
phpunit.xml
Normal file
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
verbose="true"
|
||||
stopOnFailure="false"
|
||||
processIsolation="false"
|
||||
backupGlobals="false"
|
||||
testdox="true">
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="KiviCare API Contract Tests">
|
||||
<directory>tests/contract</directory>
|
||||
</testsuite>
|
||||
<testsuite name="KiviCare API Integration Tests">
|
||||
<directory>tests/integration</directory>
|
||||
</testsuite>
|
||||
<testsuite name="KiviCare API Unit Tests">
|
||||
<directory>tests/unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="KiviCare API Performance Tests">
|
||||
<directory>tests/performance</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<coverage>
|
||||
<report>
|
||||
<html outputDirectory="coverage-html" lowUpperBound="50" highLowerBound="80"/>
|
||||
<text outputFile="coverage.txt" showUncoveredFiles="false" showOnlySummary="true"/>
|
||||
</report>
|
||||
</coverage>
|
||||
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">src</directory>
|
||||
</include>
|
||||
<exclude>
|
||||
<directory>src/vendor</directory>
|
||||
<file>src/kivicare-api.php</file>
|
||||
</exclude>
|
||||
</source>
|
||||
|
||||
<logging>
|
||||
<junit outputFile="tests/_output/junit.xml"/>
|
||||
<testdoxHtml outputFile="tests/_output/testdox.html"/>
|
||||
</logging>
|
||||
|
||||
<php>
|
||||
<server name="WP_TESTS_DIR" value="/tmp/wordpress-tests-lib"/>
|
||||
<server name="WP_CORE_DIR" value="/tmp/wordpress/"/>
|
||||
<server name="WP_TESTS_CONFIG_FILE" value="/tmp/wordpress-tests-lib/wp-tests-config.php"/>
|
||||
|
||||
<!-- WordPress Database Configuration -->
|
||||
<server name="DB_NAME" value="wordpress_test"/>
|
||||
<server name="DB_USER" value="root"/>
|
||||
<server name="DB_PASSWORD" value=""/>
|
||||
<server name="DB_HOST" value="localhost"/>
|
||||
<server name="DB_CHARSET" value="utf8"/>
|
||||
<server name="DB_COLLATE" value=""/>
|
||||
|
||||
<!-- WordPress Configuration -->
|
||||
<server name="WP_DEBUG" value="true"/>
|
||||
<server name="WP_DEBUG_LOG" value="true"/>
|
||||
<server name="WP_DEBUG_DISPLAY" value="false"/>
|
||||
<server name="SCRIPT_DEBUG" value="true"/>
|
||||
<server name="WP_TESTS_MULTISITE" value="false"/>
|
||||
|
||||
<!-- KiviCare API Test Configuration -->
|
||||
<server name="KIVICARE_API_TEST_MODE" value="true"/>
|
||||
<server name="KIVICARE_API_DEBUG" value="true"/>
|
||||
<server name="KIVICARE_JWT_SECRET" value="test-secret-key-for-jwt-tokens"/>
|
||||
<server name="KIVICARE_API_CACHE_TTL" value="60"/>
|
||||
|
||||
<!-- HTTP Test Server Configuration -->
|
||||
<server name="WP_TESTS_DOMAIN" value="example.org"/>
|
||||
<server name="WP_TESTS_EMAIL" value="admin@example.org"/>
|
||||
<server name="WP_TESTS_TITLE" value="Test Blog"/>
|
||||
<server name="WP_PHP_BINARY" value="php"/>
|
||||
<server name="WP_TESTS_FORCE_KNOWN_BUGS" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
544
specs/001-care-api-sistema/contracts/openapi.yaml
Normal file
544
specs/001-care-api-sistema/contracts/openapi.yaml
Normal file
@@ -0,0 +1,544 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: KiviCare API
|
||||
description: REST API for KiviCare healthcare management system
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: Descomplicar® Crescimento Digital
|
||||
url: https://descomplicar.pt
|
||||
|
||||
servers:
|
||||
- url: /wp-json/kivicare/v1
|
||||
description: WordPress REST API base
|
||||
|
||||
security:
|
||||
- BearerAuth: []
|
||||
|
||||
paths:
|
||||
# Authentication
|
||||
/auth/login:
|
||||
post:
|
||||
tags: [Authentication]
|
||||
summary: User login
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [username, password]
|
||||
properties:
|
||||
username: {type: string}
|
||||
password: {type: string, format: password}
|
||||
responses:
|
||||
200:
|
||||
description: Login successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
token: {type: string}
|
||||
user_id: {type: integer}
|
||||
role: {type: string, enum: [administrator, doctor, patient, receptionist]}
|
||||
expires_at: {type: string, format: date-time}
|
||||
401:
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
|
||||
# Clinics
|
||||
/clinics:
|
||||
get:
|
||||
tags: [Clinics]
|
||||
summary: List clinics
|
||||
parameters:
|
||||
- name: status
|
||||
in: query
|
||||
schema: {type: integer, enum: [0, 1]}
|
||||
responses:
|
||||
200:
|
||||
description: Clinics list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Clinic'
|
||||
post:
|
||||
tags: [Clinics]
|
||||
summary: Create clinic
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ClinicInput'
|
||||
responses:
|
||||
201:
|
||||
description: Clinic created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Clinic'
|
||||
400:
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
|
||||
/clinics/{id}:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer}
|
||||
get:
|
||||
tags: [Clinics]
|
||||
summary: Get clinic by ID
|
||||
responses:
|
||||
200:
|
||||
description: Clinic details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Clinic'
|
||||
404:
|
||||
$ref: '#/components/responses/NotFoundError'
|
||||
put:
|
||||
tags: [Clinics]
|
||||
summary: Update clinic
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ClinicInput'
|
||||
responses:
|
||||
200:
|
||||
description: Clinic updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Clinic'
|
||||
delete:
|
||||
tags: [Clinics]
|
||||
summary: Delete clinic
|
||||
responses:
|
||||
204:
|
||||
description: Clinic deleted
|
||||
409:
|
||||
description: Cannot delete clinic with active patients/doctors
|
||||
|
||||
# Patients
|
||||
/patients:
|
||||
get:
|
||||
tags: [Patients]
|
||||
summary: List patients
|
||||
parameters:
|
||||
- name: clinic_id
|
||||
in: query
|
||||
schema: {type: integer}
|
||||
- name: search
|
||||
in: query
|
||||
schema: {type: string}
|
||||
responses:
|
||||
200:
|
||||
description: Patients list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Patient'
|
||||
post:
|
||||
tags: [Patients]
|
||||
summary: Create patient
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatientInput'
|
||||
responses:
|
||||
201:
|
||||
description: Patient created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Patient'
|
||||
|
||||
/patients/{id}:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer}
|
||||
get:
|
||||
tags: [Patients]
|
||||
summary: Get patient by ID
|
||||
responses:
|
||||
200:
|
||||
description: Patient details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Patient'
|
||||
put:
|
||||
tags: [Patients]
|
||||
summary: Update patient
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatientInput'
|
||||
responses:
|
||||
200:
|
||||
description: Patient updated
|
||||
|
||||
/patients/{id}/encounters:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer}
|
||||
get:
|
||||
tags: [Patients]
|
||||
summary: Get patient encounters
|
||||
responses:
|
||||
200:
|
||||
description: Patient encounters
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Encounter'
|
||||
|
||||
# Appointments
|
||||
/appointments:
|
||||
get:
|
||||
tags: [Appointments]
|
||||
summary: List appointments
|
||||
parameters:
|
||||
- name: doctor_id
|
||||
in: query
|
||||
schema: {type: integer}
|
||||
- name: patient_id
|
||||
in: query
|
||||
schema: {type: integer}
|
||||
- name: date
|
||||
in: query
|
||||
schema: {type: string, format: date}
|
||||
responses:
|
||||
200:
|
||||
description: Appointments list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Appointment'
|
||||
post:
|
||||
tags: [Appointments]
|
||||
summary: Create appointment
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AppointmentInput'
|
||||
responses:
|
||||
201:
|
||||
description: Appointment created
|
||||
|
||||
/appointments/{id}:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer}
|
||||
get:
|
||||
tags: [Appointments]
|
||||
summary: Get appointment by ID
|
||||
responses:
|
||||
200:
|
||||
description: Appointment details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Appointment'
|
||||
put:
|
||||
tags: [Appointments]
|
||||
summary: Update appointment
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AppointmentInput'
|
||||
responses:
|
||||
200:
|
||||
description: Appointment updated
|
||||
delete:
|
||||
tags: [Appointments]
|
||||
summary: Cancel appointment
|
||||
responses:
|
||||
200:
|
||||
description: Appointment cancelled
|
||||
|
||||
# Encounters
|
||||
/encounters:
|
||||
get:
|
||||
tags: [Encounters]
|
||||
summary: List encounters
|
||||
parameters:
|
||||
- name: patient_id
|
||||
in: query
|
||||
schema: {type: integer}
|
||||
- name: doctor_id
|
||||
in: query
|
||||
schema: {type: integer}
|
||||
responses:
|
||||
200:
|
||||
description: Encounters list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Encounter'
|
||||
post:
|
||||
tags: [Encounters]
|
||||
summary: Create encounter
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EncounterInput'
|
||||
responses:
|
||||
201:
|
||||
description: Encounter created
|
||||
|
||||
/encounters/{id}:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer}
|
||||
get:
|
||||
tags: [Encounters]
|
||||
summary: Get encounter by ID
|
||||
responses:
|
||||
200:
|
||||
description: Encounter details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Encounter'
|
||||
put:
|
||||
tags: [Encounters]
|
||||
summary: Update encounter
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EncounterInput'
|
||||
responses:
|
||||
200:
|
||||
description: Encounter updated
|
||||
|
||||
/encounters/{id}/prescriptions:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer}
|
||||
get:
|
||||
tags: [Prescriptions]
|
||||
summary: Get encounter prescriptions
|
||||
responses:
|
||||
200:
|
||||
description: Prescriptions list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Prescription'
|
||||
post:
|
||||
tags: [Prescriptions]
|
||||
summary: Add prescription to encounter
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrescriptionInput'
|
||||
responses:
|
||||
201:
|
||||
description: Prescription added
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
|
||||
schemas:
|
||||
Clinic:
|
||||
type: object
|
||||
properties:
|
||||
id: {type: integer}
|
||||
name: {type: string}
|
||||
email: {type: string, format: email}
|
||||
telephone_no: {type: string}
|
||||
address: {type: string}
|
||||
city: {type: string}
|
||||
state: {type: string}
|
||||
country: {type: string}
|
||||
postal_code: {type: string}
|
||||
status: {type: integer, enum: [0, 1]}
|
||||
created_at: {type: string, format: date-time}
|
||||
|
||||
ClinicInput:
|
||||
type: object
|
||||
required: [name, email, telephone_no]
|
||||
properties:
|
||||
name: {type: string, maxLength: 191}
|
||||
email: {type: string, format: email, maxLength: 191}
|
||||
telephone_no: {type: string, maxLength: 191}
|
||||
address: {type: string}
|
||||
city: {type: string, maxLength: 191}
|
||||
state: {type: string, maxLength: 191}
|
||||
country: {type: string, maxLength: 191}
|
||||
postal_code: {type: string, maxLength: 191}
|
||||
|
||||
Patient:
|
||||
type: object
|
||||
properties:
|
||||
id: {type: integer}
|
||||
display_name: {type: string}
|
||||
user_email: {type: string, format: email}
|
||||
phone: {type: string}
|
||||
birth_date: {type: string, format: date}
|
||||
gender: {type: string, enum: [M, F, Other]}
|
||||
clinic_id: {type: integer}
|
||||
registration_date: {type: string, format: date-time}
|
||||
|
||||
PatientInput:
|
||||
type: object
|
||||
required: [display_name, user_email, clinic_id]
|
||||
properties:
|
||||
display_name: {type: string, maxLength: 250}
|
||||
user_email: {type: string, format: email, maxLength: 100}
|
||||
phone: {type: string}
|
||||
birth_date: {type: string, format: date}
|
||||
gender: {type: string, enum: [M, F, Other]}
|
||||
clinic_id: {type: integer}
|
||||
|
||||
Appointment:
|
||||
type: object
|
||||
properties:
|
||||
id: {type: integer}
|
||||
appointment_start_date: {type: string, format: date}
|
||||
appointment_start_time: {type: string, format: time}
|
||||
appointment_end_date: {type: string, format: date}
|
||||
appointment_end_time: {type: string, format: time}
|
||||
visit_type: {type: string}
|
||||
clinic_id: {type: integer}
|
||||
doctor_id: {type: integer}
|
||||
patient_id: {type: integer}
|
||||
description: {type: string}
|
||||
status: {type: integer, enum: [0, 1, 2, 3]}
|
||||
created_at: {type: string, format: date-time}
|
||||
|
||||
AppointmentInput:
|
||||
type: object
|
||||
required: [appointment_start_date, appointment_start_time, doctor_id, patient_id, clinic_id]
|
||||
properties:
|
||||
appointment_start_date: {type: string, format: date}
|
||||
appointment_start_time: {type: string, format: time}
|
||||
appointment_end_date: {type: string, format: date}
|
||||
appointment_end_time: {type: string, format: time}
|
||||
visit_type: {type: string}
|
||||
doctor_id: {type: integer}
|
||||
patient_id: {type: integer}
|
||||
clinic_id: {type: integer}
|
||||
description: {type: string}
|
||||
|
||||
Encounter:
|
||||
type: object
|
||||
properties:
|
||||
id: {type: integer}
|
||||
encounter_date: {type: string, format: date}
|
||||
clinic_id: {type: integer}
|
||||
doctor_id: {type: integer}
|
||||
patient_id: {type: integer}
|
||||
appointment_id: {type: integer}
|
||||
description: {type: string}
|
||||
status: {type: integer, enum: [0, 1]}
|
||||
created_at: {type: string, format: date-time}
|
||||
|
||||
EncounterInput:
|
||||
type: object
|
||||
required: [appointment_id, description]
|
||||
properties:
|
||||
appointment_id: {type: integer}
|
||||
description: {type: string}
|
||||
status: {type: integer, enum: [0, 1]}
|
||||
|
||||
Prescription:
|
||||
type: object
|
||||
properties:
|
||||
id: {type: integer}
|
||||
encounter_id: {type: integer}
|
||||
patient_id: {type: integer}
|
||||
name: {type: string}
|
||||
frequency: {type: string}
|
||||
duration: {type: string}
|
||||
instruction: {type: string}
|
||||
created_at: {type: string, format: date-time}
|
||||
|
||||
PrescriptionInput:
|
||||
type: object
|
||||
required: [name, frequency, duration]
|
||||
properties:
|
||||
name: {type: string}
|
||||
frequency: {type: string, maxLength: 199}
|
||||
duration: {type: string, maxLength: 199}
|
||||
instruction: {type: string}
|
||||
|
||||
responses:
|
||||
UnauthorizedError:
|
||||
description: Authentication required
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: "rest_forbidden"}
|
||||
message: {type: string}
|
||||
data: {type: object, properties: {status: {type: integer}}}
|
||||
|
||||
ValidationError:
|
||||
description: Validation failed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: "rest_invalid_param"}
|
||||
message: {type: string}
|
||||
data: {type: object}
|
||||
|
||||
NotFoundError:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: "rest_not_found"}
|
||||
message: {type: string}
|
||||
data: {type: object, properties: {status: {type: integer, example: 404}}}
|
||||
252
specs/001-care-api-sistema/data-model.md
Normal file
252
specs/001-care-api-sistema/data-model.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Data Model: Care API System
|
||||
|
||||
**Feature**: Care API - Sistema de gestão de cuidados de saúde
|
||||
**Date**: 2025-09-12
|
||||
**Based on**: KiviCare 35-table database schema
|
||||
|
||||
## Core Entities
|
||||
|
||||
### Clinic
|
||||
**Purpose**: Healthcare facility management
|
||||
**Table**: `wp_kc_clinics`
|
||||
**Fields**:
|
||||
- `id` (bigint, PK): Unique clinic identifier
|
||||
- `name` (varchar 191): Clinic name
|
||||
- `email` (varchar 191): Contact email
|
||||
- `telephone_no` (varchar 191): Phone number
|
||||
- `specialties` (longtext): JSON array of medical specialties
|
||||
- `address` (text): Physical address
|
||||
- `city`, `state`, `country` (varchar 191): Location details
|
||||
- `postal_code` (varchar 191): ZIP/postal code
|
||||
- `status` (tinyint): Active/inactive status
|
||||
- `clinic_admin_id` (bigint): Administrator user ID
|
||||
- `created_at` (datetime): Record creation timestamp
|
||||
|
||||
**Validation Rules**:
|
||||
- Name: Required, 1-191 characters
|
||||
- Email: Valid email format, unique per clinic
|
||||
- Status: Must be 0 (inactive) or 1 (active)
|
||||
- Admin ID: Must reference valid wp_users record
|
||||
|
||||
**Relationships**:
|
||||
- Has many doctors (via wp_kc_doctor_clinic_mappings)
|
||||
- Has many patients (via wp_kc_patient_clinic_mappings)
|
||||
- Has many appointments
|
||||
- Belongs to admin user (wp_users)
|
||||
|
||||
### Patient
|
||||
**Purpose**: Individual receiving healthcare
|
||||
**Tables**: `wp_users` + `wp_kc_patient_clinic_mappings`
|
||||
**Fields** (combined):
|
||||
- `ID` (bigint, PK): WordPress user ID
|
||||
- `user_login` (varchar 60): Username
|
||||
- `user_email` (varchar 100): Email address
|
||||
- `display_name` (varchar 250): Full name
|
||||
- `clinic_id` (bigint): Associated clinic
|
||||
- `registration_date` (datetime): Account creation
|
||||
- Meta fields: phone, birth_date, gender, address
|
||||
|
||||
**Validation Rules**:
|
||||
- Email: Required, valid format, unique
|
||||
- Display name: Required, 1-250 characters
|
||||
- Phone: Valid phone number format
|
||||
- Birth date: Valid date, not future
|
||||
- Gender: M/F/Other or empty
|
||||
|
||||
**Relationships**:
|
||||
- Belongs to clinic (wp_kc_clinics)
|
||||
- Has many appointments
|
||||
- Has many encounters
|
||||
- Has many prescriptions
|
||||
- Has medical history entries
|
||||
|
||||
### Doctor
|
||||
**Purpose**: Healthcare provider
|
||||
**Tables**: `wp_users` + `wp_kc_doctor_clinic_mappings`
|
||||
**Fields** (combined):
|
||||
- `ID` (bigint, PK): WordPress user ID
|
||||
- `user_login` (varchar 60): Username
|
||||
- `user_email` (varchar 100): Email address
|
||||
- `display_name` (varchar 250): Full name
|
||||
- `clinic_id` (bigint): Primary clinic
|
||||
- Meta fields: specialization, qualifications, schedule
|
||||
|
||||
**Validation Rules**:
|
||||
- Must have 'doctor' role in WordPress
|
||||
- Specialization: From predefined list
|
||||
- Schedule: Valid time slots format
|
||||
|
||||
**Relationships**:
|
||||
- Belongs to clinics (many-to-many)
|
||||
- Has many appointments
|
||||
- Conducts encounters
|
||||
- Creates prescriptions
|
||||
|
||||
### Appointment
|
||||
**Purpose**: Scheduled healthcare visit
|
||||
**Table**: `wp_kc_appointments`
|
||||
**Fields**:
|
||||
- `id` (bigint, PK): Unique appointment ID
|
||||
- `appointment_start_date` (date): Visit date
|
||||
- `appointment_start_time` (time): Start time
|
||||
- `appointment_end_date` (date): End date
|
||||
- `appointment_end_time` (time): End time
|
||||
- `visit_type` (varchar 191): consultation/follow-up/emergency
|
||||
- `clinic_id` (bigint): FK to wp_kc_clinics
|
||||
- `doctor_id` (bigint): FK to wp_users
|
||||
- `patient_id` (bigint): FK to wp_users
|
||||
- `description` (text): Appointment notes
|
||||
- `status` (tinyint): 0=cancelled, 1=scheduled, 2=completed, 3=no-show
|
||||
- `created_at` (datetime): Record creation
|
||||
|
||||
**Validation Rules**:
|
||||
- Start time must be before end time
|
||||
- Cannot schedule in the past (except admin override)
|
||||
- Doctor and patient must belong to same clinic
|
||||
- Status: Must be valid enum value
|
||||
|
||||
**State Transitions**:
|
||||
```
|
||||
scheduled → completed (normal flow)
|
||||
scheduled → cancelled (user action)
|
||||
scheduled → no-show (admin action)
|
||||
completed → [terminal state]
|
||||
```
|
||||
|
||||
**Relationships**:
|
||||
- Belongs to clinic, doctor, patient
|
||||
- Has many service mappings
|
||||
- May have one encounter
|
||||
- May generate bills
|
||||
|
||||
### Encounter
|
||||
**Purpose**: Actual medical consultation record
|
||||
**Table**: `wp_kc_patient_encounters`
|
||||
**Fields**:
|
||||
- `id` (bigint, PK): Unique encounter ID
|
||||
- `encounter_date` (date): Consultation date
|
||||
- `clinic_id` (bigint): FK to wp_kc_clinics
|
||||
- `doctor_id` (bigint): FK to wp_users
|
||||
- `patient_id` (bigint): FK to wp_users
|
||||
- `appointment_id` (bigint): FK to wp_kc_appointments
|
||||
- `description` (text): Medical notes/diagnosis
|
||||
- `status` (tinyint): 0=draft, 1=completed
|
||||
- `added_by` (bigint): Creating user ID
|
||||
- `created_at` (datetime): Record creation
|
||||
- `template_id` (bigint): Optional template reference
|
||||
|
||||
**Validation Rules**:
|
||||
- Must link to valid appointment
|
||||
- Doctor must match appointment doctor
|
||||
- Status: 0 or 1 only
|
||||
- Description: Required for completed encounters
|
||||
|
||||
**Relationships**:
|
||||
- Belongs to appointment, clinic, doctor, patient
|
||||
- Has many prescriptions
|
||||
- Has medical history entries
|
||||
- May generate bills
|
||||
|
||||
### Prescription
|
||||
**Purpose**: Medication orders
|
||||
**Table**: `wp_kc_prescription`
|
||||
**Fields**:
|
||||
- `id` (bigint, PK): Unique prescription ID
|
||||
- `encounter_id` (bigint): FK to wp_kc_patient_encounters
|
||||
- `patient_id` (bigint): FK to wp_users
|
||||
- `name` (text): Medication name
|
||||
- `frequency` (varchar 199): Dosage frequency
|
||||
- `duration` (varchar 199): Treatment duration
|
||||
- `instruction` (text): Special instructions
|
||||
- `added_by` (bigint): Prescribing doctor ID
|
||||
- `created_at` (datetime): Record creation
|
||||
|
||||
**Validation Rules**:
|
||||
- Name: Required medication name
|
||||
- Frequency: Standard medical frequency format
|
||||
- Duration: Valid time period
|
||||
- Must be created by doctor role user
|
||||
|
||||
**Relationships**:
|
||||
- Belongs to encounter and patient
|
||||
- Created by doctor (added_by)
|
||||
|
||||
### Bill
|
||||
**Purpose**: Financial records for services
|
||||
**Table**: `wp_kc_bills`
|
||||
**Fields**:
|
||||
- `id` (bigint, PK): Unique bill ID
|
||||
- `encounter_id` (bigint): FK to wp_kc_patient_encounters
|
||||
- `appointment_id` (bigint): FK to wp_kc_appointments
|
||||
- `title` (varchar 191): Bill description
|
||||
- `total_amount` (varchar 50): Total charges
|
||||
- `discount` (varchar 50): Discount applied
|
||||
- `actual_amount` (varchar 50): Final amount
|
||||
- `status` (bigint): Bill status
|
||||
- `payment_status` (varchar 10): paid/pending/overdue
|
||||
- `created_at` (datetime): Record creation
|
||||
- `clinic_id` (bigint): FK to clinic
|
||||
|
||||
**Validation Rules**:
|
||||
- Amounts: Valid decimal format
|
||||
- Payment status: Must be valid enum
|
||||
- Total = actual + discount
|
||||
|
||||
**Relationships**:
|
||||
- Belongs to encounter, appointment, clinic
|
||||
- May have payment records
|
||||
|
||||
### Service
|
||||
**Purpose**: Medical services offered
|
||||
**Table**: `wp_kc_services`
|
||||
**Fields**:
|
||||
- `id` (bigint, PK): Service ID
|
||||
- `type` (varchar): Service category
|
||||
- `name` (varchar): Service name
|
||||
- `price` (decimal): Service cost
|
||||
- `status` (tinyint): Active/inactive
|
||||
- `created_at` (datetime): Record creation
|
||||
|
||||
**Validation Rules**:
|
||||
- Name: Required, unique per clinic
|
||||
- Price: Non-negative decimal
|
||||
- Status: 0 or 1
|
||||
|
||||
**Relationships**:
|
||||
- Can be mapped to appointments
|
||||
- Used in billing calculations
|
||||
|
||||
## Entity Relationships Summary
|
||||
|
||||
```
|
||||
Clinic (1) ←→ (M) Doctor (M) ←→ (M) Patient
|
||||
↓ ↓ ↓
|
||||
Appointments ←→ Encounters ←→ Prescriptions
|
||||
↓ ↓
|
||||
Services ←→ Bills
|
||||
```
|
||||
|
||||
## Data Integrity Constraints
|
||||
|
||||
1. **Referential Integrity**: All foreign keys must reference valid records
|
||||
2. **Clinic Isolation**: Users can only access data from their authorized clinics
|
||||
3. **Role Constraints**: Only doctors can create encounters and prescriptions
|
||||
4. **Temporal Constraints**: Appointments cannot be scheduled in conflicting time slots
|
||||
5. **Status Consistency**: Related records must maintain consistent status values
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
**Indexes Required**:
|
||||
- `wp_kc_appointments`: (doctor_id, appointment_start_date)
|
||||
- `wp_kc_patient_encounters`: (patient_id, encounter_date)
|
||||
- `wp_kc_bills`: (clinic_id, status)
|
||||
- `wp_kc_prescription`: (encounter_id)
|
||||
|
||||
**Caching Strategy**:
|
||||
- Patient encounters: 1 hour TTL
|
||||
- Appointment schedules: 30 minutes TTL
|
||||
- Clinic information: 24 hours TTL
|
||||
|
||||
---
|
||||
|
||||
**Data Model Complete**: Ready for API contract generation
|
||||
253
specs/001-care-api-sistema/plan.md
Normal file
253
specs/001-care-api-sistema/plan.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# Implementation Plan: Care API - Sistema de gestão de cuidados de saúde
|
||||
|
||||
**Branch**: `001-care-api-sistema` | **Date**: 2025-09-12 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/001-care-api-sistema/spec.md`
|
||||
|
||||
## Execution Flow (/plan command scope)
|
||||
```
|
||||
1. Load feature spec from Input path
|
||||
→ If not found: ERROR "No feature spec at {path}"
|
||||
2. Fill Technical Context (scan for NEEDS CLARIFICATION)
|
||||
→ Detect Project Type from context (web=frontend+backend, mobile=app+api)
|
||||
→ Set Structure Decision based on project type
|
||||
3. Evaluate Constitution Check section below
|
||||
→ If violations exist: Document in Complexity Tracking
|
||||
→ If no justification possible: ERROR "Simplify approach first"
|
||||
→ Update Progress Tracking: Initial Constitution Check
|
||||
4. Execute Phase 0 → research.md
|
||||
→ If NEEDS CLARIFICATION remain: ERROR "Resolve unknowns"
|
||||
5. Execute Phase 1 → contracts, data-model.md, quickstart.md, agent-specific template file (e.g., `CLAUDE.md` for Claude Code, `.github/copilot-instructions.md` for GitHub Copilot, or `GEMINI.md` for Gemini CLI).
|
||||
6. Re-evaluate Constitution Check section
|
||||
→ If new violations: Refactor design, return to Phase 1
|
||||
→ Update Progress Tracking: Post-Design Constitution Check
|
||||
7. Plan Phase 2 → Describe task generation approach (DO NOT create tasks.md)
|
||||
8. STOP - Ready for /tasks command
|
||||
```
|
||||
|
||||
**IMPORTANT**: The /plan command STOPS at step 7. Phases 2-4 are executed by other commands:
|
||||
- Phase 2: /tasks command creates tasks.md
|
||||
- Phase 3-4: Implementation execution (manual or via tools)
|
||||
|
||||
## Summary
|
||||
Develop a comprehensive REST API for KiviCare WordPress plugin to enable healthcare professionals to manage clinic operations, patient records, appointments, medical encounters, prescriptions, and billing through API endpoints. The implementation must maintain data integrity across the existing 35-table KiviCare database schema while providing role-based access control for administrators, doctors, patients, and receptionists.
|
||||
|
||||
## Technical Context
|
||||
**Language/Version**: PHP 8.1+ (WordPress compatibility)
|
||||
**Primary Dependencies**: WordPress 6.0+, KiviCare plugin, JWT authentication library
|
||||
**Storage**: MySQL (existing KiviCare 35-table schema)
|
||||
**Testing**: PHPUnit, WP-CLI testing framework
|
||||
**Target Platform**: WordPress hosting environments (Linux/Apache/Nginx)
|
||||
**Project Type**: single (WordPress plugin extension)
|
||||
**Performance Goals**: <500ms API response time, support 100 concurrent users
|
||||
**Constraints**: WordPress hosting compatibility, preserve existing KiviCare data integrity, GDPR compliance
|
||||
**Scale/Scope**: Multi-clinic healthcare management, 1000+ patients per clinic, full medical workflow coverage
|
||||
|
||||
## Constitution Check
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
**Simplicity**:
|
||||
- Projects: 1 (WordPress plugin only)
|
||||
- Using framework directly? (Yes - WordPress REST API directly)
|
||||
- Single data model? (Yes - direct KiviCare entities without DTOs)
|
||||
- Avoiding patterns? (Yes - no Repository pattern, direct WordPress database access)
|
||||
|
||||
**Architecture**:
|
||||
- EVERY feature as library? (Yes - endpoint libraries, model libraries, auth library)
|
||||
- Libraries listed: endpoint handlers (API routes), data models (KiviCare entities), auth service (JWT/role management)
|
||||
- CLI per library: WP-CLI commands for testing/debugging endpoints
|
||||
- Library docs: Yes, llms.txt format for API documentation
|
||||
|
||||
**Testing (NON-NEGOTIABLE)**:
|
||||
- RED-GREEN-Refactor cycle enforced? (Yes - PHPUnit tests fail first)
|
||||
- Git commits show tests before implementation? (Yes - test commits before feature commits)
|
||||
- Order: Contract→Integration→E2E→Unit strictly followed? (Yes - API contract tests first)
|
||||
- Real dependencies used? (Yes - actual WordPress/MySQL database)
|
||||
- Integration tests for: API endpoints, database operations, authentication flows
|
||||
- FORBIDDEN: Implementation before test, skipping RED phase (Acknowledged)
|
||||
|
||||
**Observability**:
|
||||
- Structured logging included? (Yes - WordPress debug.log with structured format)
|
||||
- Frontend logs → backend? (N/A - pure API, no frontend)
|
||||
- Error context sufficient? (Yes - HTTP status codes with detailed JSON error responses)
|
||||
|
||||
**Versioning**:
|
||||
- Version number assigned? (1.0.0 - WordPress plugin versioning)
|
||||
- BUILD increments on every change? (Yes - WordPress plugin version updates)
|
||||
- Breaking changes handled? (Yes - API versioning via /v1/ namespace, backward compatibility)
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
```
|
||||
specs/[###-feature]/
|
||||
├── plan.md # This file (/plan command output)
|
||||
├── research.md # Phase 0 output (/plan command)
|
||||
├── data-model.md # Phase 1 output (/plan command)
|
||||
├── quickstart.md # Phase 1 output (/plan command)
|
||||
├── contracts/ # Phase 1 output (/plan command)
|
||||
└── tasks.md # Phase 2 output (/tasks command - NOT created by /plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
```
|
||||
# Option 1: Single project (DEFAULT)
|
||||
src/
|
||||
├── models/
|
||||
├── services/
|
||||
├── cli/
|
||||
└── lib/
|
||||
|
||||
tests/
|
||||
├── contract/
|
||||
├── integration/
|
||||
└── unit/
|
||||
|
||||
# Option 2: Web application (when "frontend" + "backend" detected)
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── models/
|
||||
│ ├── services/
|
||||
│ └── api/
|
||||
└── tests/
|
||||
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ └── services/
|
||||
└── tests/
|
||||
|
||||
# Option 3: Mobile + API (when "iOS/Android" detected)
|
||||
api/
|
||||
└── [same as backend above]
|
||||
|
||||
ios/ or android/
|
||||
└── [platform-specific structure]
|
||||
```
|
||||
|
||||
**Structure Decision**: Option 1 (Single WordPress plugin project structure)
|
||||
|
||||
## Phase 0: Outline & Research
|
||||
1. **Extract unknowns from Technical Context** above:
|
||||
- For each NEEDS CLARIFICATION → research task
|
||||
- For each dependency → best practices task
|
||||
- For each integration → patterns task
|
||||
|
||||
2. **Generate and dispatch research agents**:
|
||||
```
|
||||
For each unknown in Technical Context:
|
||||
Task: "Research {unknown} for {feature context}"
|
||||
For each technology choice:
|
||||
Task: "Find best practices for {tech} in {domain}"
|
||||
```
|
||||
|
||||
3. **Consolidate findings** in `research.md` using format:
|
||||
- Decision: [what was chosen]
|
||||
- Rationale: [why chosen]
|
||||
- Alternatives considered: [what else evaluated]
|
||||
|
||||
**Output**: research.md with all NEEDS CLARIFICATION resolved
|
||||
|
||||
## Phase 1: Design & Contracts
|
||||
*Prerequisites: research.md complete*
|
||||
|
||||
1. **Extract entities from feature spec** → `data-model.md`:
|
||||
- Entity name, fields, relationships
|
||||
- Validation rules from requirements
|
||||
- State transitions if applicable
|
||||
|
||||
2. **Generate API contracts** from functional requirements:
|
||||
- For each user action → endpoint
|
||||
- Use standard REST/GraphQL patterns
|
||||
- Output OpenAPI/GraphQL schema to `/contracts/`
|
||||
|
||||
3. **Generate contract tests** from contracts:
|
||||
- One test file per endpoint
|
||||
- Assert request/response schemas
|
||||
- Tests must fail (no implementation yet)
|
||||
|
||||
4. **Extract test scenarios** from user stories:
|
||||
- Each story → integration test scenario
|
||||
- Quickstart test = story validation steps
|
||||
|
||||
5. **Update agent file incrementally** (O(1) operation):
|
||||
- Run `/scripts/update-agent-context.sh [claude|gemini|copilot]` for your AI assistant
|
||||
- If exists: Add only NEW tech from current plan
|
||||
- Preserve manual additions between markers
|
||||
- Update recent changes (keep last 3)
|
||||
- Keep under 150 lines for token efficiency
|
||||
- Output to repository root
|
||||
|
||||
**Output**: data-model.md, /contracts/*, failing tests, quickstart.md, agent-specific file
|
||||
|
||||
## Phase 2: Task Planning Approach
|
||||
*This section describes what the /tasks command will do - DO NOT execute during /plan*
|
||||
|
||||
**Task Generation Strategy**:
|
||||
- Load `/templates/tasks-template.md` as base
|
||||
- Generate tasks from OpenAPI contracts (8 main endpoints)
|
||||
- Generate tasks from data model (8 entities with validation)
|
||||
- Generate tasks from quickstart validation scenarios (5 user stories)
|
||||
- Each API endpoint → contract test task [P]
|
||||
- Each entity model → model creation + unit test tasks [P]
|
||||
- Each user story → integration test scenario
|
||||
- Authentication/authorization → JWT service implementation tasks
|
||||
- WordPress plugin structure → activation/setup tasks
|
||||
|
||||
**Ordering Strategy**:
|
||||
- TDD order: Contract tests → Integration tests → Unit tests → Implementation
|
||||
- Dependency order: Plugin setup → Models → Auth service → Endpoints → Integration
|
||||
- WordPress-specific: Database setup → User roles → REST endpoints → Testing
|
||||
- Mark [P] for parallel execution (independent endpoint files)
|
||||
|
||||
**Specific Task Categories**:
|
||||
1. **Setup Tasks** (1-5): Plugin structure, WordPress integration, database setup
|
||||
2. **Contract Test Tasks** (6-15): API endpoint contract tests (must fail initially)
|
||||
3. **Model Tasks** (16-25): Entity classes with validation, database operations
|
||||
4. **Authentication Tasks** (26-30): JWT service, role-based permissions
|
||||
5. **Endpoint Implementation Tasks** (31-45): REST API endpoint handlers
|
||||
6. **Integration Test Tasks** (46-55): End-to-end user story validation
|
||||
7. **Performance/Error Handling** (56-60): Caching, logging, error responses
|
||||
|
||||
**Estimated Output**: 55-60 numbered, ordered tasks in tasks.md following WordPress TDD practices
|
||||
|
||||
**IMPORTANT**: This phase is executed by the /tasks command, NOT by /plan
|
||||
|
||||
## Phase 3+: Future Implementation
|
||||
*These phases are beyond the scope of the /plan command*
|
||||
|
||||
**Phase 3**: Task execution (/tasks command creates tasks.md)
|
||||
**Phase 4**: Implementation (execute tasks.md following constitutional principles)
|
||||
**Phase 5**: Validation (run tests, execute quickstart.md, performance validation)
|
||||
|
||||
## Complexity Tracking
|
||||
*Fill ONLY if Constitution Check has violations that must be justified*
|
||||
|
||||
No constitutional violations identified. All complexity is justified:
|
||||
- Single WordPress plugin project (within 3-project limit)
|
||||
- Direct framework usage (WordPress REST API, no wrappers)
|
||||
- No unnecessary patterns (direct database access via $wpdb)
|
||||
- TDD enforced with contract tests before implementation
|
||||
- Libraries properly structured (models, services, endpoints)
|
||||
- Structured logging and versioning planned
|
||||
|
||||
|
||||
## Progress Tracking
|
||||
*This checklist is updated during execution flow*
|
||||
|
||||
**Phase Status**:
|
||||
- [x] Phase 0: Research complete (/plan command)
|
||||
- [x] Phase 1: Design complete (/plan command)
|
||||
- [x] Phase 2: Task planning complete (/plan command - describe approach only)
|
||||
- [ ] Phase 3: Tasks generated (/tasks command)
|
||||
- [ ] Phase 4: Implementation complete
|
||||
- [ ] Phase 5: Validation passed
|
||||
|
||||
**Gate Status**:
|
||||
- [x] Initial Constitution Check: PASS
|
||||
- [x] Post-Design Constitution Check: PASS
|
||||
- [x] All NEEDS CLARIFICATION resolved
|
||||
- [x] Complexity deviations documented (none found)
|
||||
|
||||
---
|
||||
*Based on Constitution v2.1.1 - See `/memory/constitution.md`*
|
||||
246
specs/001-care-api-sistema/quickstart.md
Normal file
246
specs/001-care-api-sistema/quickstart.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Quickstart Guide: Care API System
|
||||
|
||||
**Feature**: Care API - Sistema de gestão de cuidados de saúde
|
||||
**Date**: 2025-09-12
|
||||
**Prerequisites**: WordPress + KiviCare plugin installed
|
||||
|
||||
## Quick Setup
|
||||
|
||||
### 1. Plugin Installation
|
||||
```bash
|
||||
# Clone/download plugin to WordPress plugins directory
|
||||
wp plugin activate kivicare-api
|
||||
|
||||
# Verify KiviCare dependency
|
||||
wp plugin is-active kivicare-clinic-patient-management-system
|
||||
```
|
||||
|
||||
### 2. Authentication Setup
|
||||
```bash
|
||||
# Test JWT authentication
|
||||
curl -X POST http://your-site.local/wp-json/kivicare/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"doctor_user","password":"password"}'
|
||||
|
||||
# Expected response:
|
||||
# {"token":"eyJ0eXAiOiJKV1QiLCJhbGc...","user_id":123,"role":"doctor"}
|
||||
```
|
||||
|
||||
### 3. Basic API Testing
|
||||
|
||||
#### Create a Patient
|
||||
```bash
|
||||
export TOKEN="your_jwt_token_here"
|
||||
|
||||
curl -X POST http://your-site.local/wp-json/kivicare/v1/patients \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"display_name": "João Silva",
|
||||
"user_email": "joao@example.com",
|
||||
"clinic_id": 1,
|
||||
"phone": "+351912345678"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Schedule an Appointment
|
||||
```bash
|
||||
curl -X POST http://your-site.local/wp-json/kivicare/v1/appointments \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"appointment_start_date": "2025-01-15",
|
||||
"appointment_start_time": "14:30:00",
|
||||
"appointment_end_date": "2025-01-15",
|
||||
"appointment_end_time": "15:00:00",
|
||||
"doctor_id": 2,
|
||||
"patient_id": 123,
|
||||
"clinic_id": 1,
|
||||
"visit_type": "consultation",
|
||||
"description": "Consulta de rotina"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Create Medical Encounter
|
||||
```bash
|
||||
curl -X POST http://your-site.local/wp-json/kivicare/v1/encounters \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"appointment_id": 456,
|
||||
"description": "Patient presents with mild fever. Diagnosed with common cold. Prescribed rest and medication.",
|
||||
"status": 1
|
||||
}'
|
||||
```
|
||||
|
||||
#### Add Prescription
|
||||
```bash
|
||||
curl -X POST http://your-site.local/wp-json/kivicare/v1/encounters/789/prescriptions \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Paracetamol 500mg",
|
||||
"frequency": "Every 8 hours",
|
||||
"duration": "7 days",
|
||||
"instruction": "Take with water after meals"
|
||||
}'
|
||||
```
|
||||
|
||||
## User Story Validation
|
||||
|
||||
### Story 1: Doctor Creates Patient Record
|
||||
```bash
|
||||
# Test: Create patient → Verify in database
|
||||
curl -X POST .../patients -d '{"display_name":"Test Patient","user_email":"test@example.com","clinic_id":1}'
|
||||
# Expected: Patient ID returned, entries in wp_users and wp_kc_patient_clinic_mappings
|
||||
|
||||
# Validate:
|
||||
wp db query "SELECT u.ID, u.display_name, pcm.clinic_id FROM wp_users u
|
||||
JOIN wp_kc_patient_clinic_mappings pcm ON u.ID = pcm.patient_id
|
||||
WHERE u.user_email = 'test@example.com'"
|
||||
```
|
||||
|
||||
### Story 2: Doctor Creates Encounter with Prescriptions
|
||||
```bash
|
||||
# Test: Create encounter → Add prescriptions → Verify linkage
|
||||
curl -X POST .../encounters -d '{"appointment_id":123,"description":"Test encounter"}'
|
||||
curl -X POST .../encounters/456/prescriptions -d '{"name":"Test Med","frequency":"Daily","duration":"5 days"}'
|
||||
|
||||
# Validate:
|
||||
wp db query "SELECT e.id, e.description, p.name, p.frequency FROM wp_kc_patient_encounters e
|
||||
JOIN wp_kc_prescription p ON e.id = p.encounter_id WHERE e.id = 456"
|
||||
```
|
||||
|
||||
### Story 3: Multi-Doctor Clinic Data Access
|
||||
```bash
|
||||
# Test: Doctor A creates encounter → Doctor B retrieves patient data
|
||||
# (Using different JWT tokens for each doctor)
|
||||
|
||||
# Doctor A creates encounter
|
||||
curl -X POST .../encounters -H "Authorization: Bearer $DOCTOR_A_TOKEN" -d '{...}'
|
||||
|
||||
# Doctor B retrieves patient encounters
|
||||
curl -X GET .../patients/123/encounters -H "Authorization: Bearer $DOCTOR_B_TOKEN"
|
||||
# Expected: Both doctors see the same encounter data (same clinic)
|
||||
```
|
||||
|
||||
### Story 4: Automatic Billing Generation
|
||||
```bash
|
||||
# Test: Complete encounter → Verify bill creation
|
||||
curl -X PUT .../encounters/456 -d '{"status":1,"description":"Completed consultation"}'
|
||||
|
||||
# Validate billing:
|
||||
wp db query "SELECT b.id, b.encounter_id, b.total_amount FROM wp_kc_bills b
|
||||
WHERE b.encounter_id = 456"
|
||||
# Expected: Bill record automatically created
|
||||
```
|
||||
|
||||
### Story 5: Role-Based Access Control
|
||||
```bash
|
||||
# Test receptionist access
|
||||
curl -X GET .../appointments -H "Authorization: Bearer $RECEPTIONIST_TOKEN"
|
||||
# Expected: Success - receptionists can manage appointments
|
||||
|
||||
curl -X GET .../encounters -H "Authorization: Bearer $RECEPTIONIST_TOKEN"
|
||||
# Expected: Error 403 - receptionists cannot access medical data
|
||||
|
||||
curl -X GET .../encounters -H "Authorization: Bearer $PATIENT_TOKEN"
|
||||
# Expected: Only own encounters returned
|
||||
```
|
||||
|
||||
## Performance Validation
|
||||
|
||||
### Response Time Tests
|
||||
```bash
|
||||
# Test appointment listing performance
|
||||
time curl -X GET "http://your-site.local/wp-json/kivicare/v1/appointments?doctor_id=2&date=2025-01-15" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
# Target: < 500ms response time
|
||||
|
||||
# Test patient encounter history
|
||||
time curl -X GET "http://your-site.local/wp-json/kivicare/v1/patients/123/encounters" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
# Target: < 500ms for 100+ encounters
|
||||
```
|
||||
|
||||
### Concurrent User Test
|
||||
```bash
|
||||
# Simulate 10 concurrent appointment creations
|
||||
for i in {1..10}; do
|
||||
curl -X POST .../appointments -H "Authorization: Bearer $TOKEN" -d '{...}' &
|
||||
done
|
||||
wait
|
||||
# Expected: All succeed without conflicts
|
||||
```
|
||||
|
||||
## Error Handling Validation
|
||||
|
||||
### Authentication Errors
|
||||
```bash
|
||||
# Test invalid token
|
||||
curl -X GET .../patients -H "Authorization: Bearer invalid_token"
|
||||
# Expected: 401 {"code":"rest_forbidden","message":"..."}
|
||||
|
||||
# Test expired token
|
||||
curl -X GET .../patients -H "Authorization: Bearer expired_token"
|
||||
# Expected: 401 with token expiry message
|
||||
```
|
||||
|
||||
### Data Validation Errors
|
||||
```bash
|
||||
# Test invalid patient data
|
||||
curl -X POST .../patients -d '{"user_email":"invalid-email","clinic_id":"not_a_number"}'
|
||||
# Expected: 400 {"code":"rest_invalid_param","message":"Validation failed","data":{...}}
|
||||
|
||||
# Test conflicting appointment
|
||||
curl -X POST .../appointments -d '{"doctor_id":2,"appointment_start_date":"2025-01-15","appointment_start_time":"14:30:00",...}'
|
||||
curl -X POST .../appointments -d '{"doctor_id":2,"appointment_start_date":"2025-01-15","appointment_start_time":"14:45:00",...}'
|
||||
# Expected: Second request fails with time conflict error
|
||||
```
|
||||
|
||||
### Database Integrity Tests
|
||||
```bash
|
||||
# Test foreign key constraints
|
||||
curl -X POST .../encounters -d '{"appointment_id":99999,"description":"Test"}'
|
||||
# Expected: 400 error - invalid appointment_id
|
||||
|
||||
# Test clinic isolation
|
||||
curl -X GET .../patients?clinic_id=2 -H "Authorization: Bearer $CLINIC_1_DOCTOR_TOKEN"
|
||||
# Expected: 403 error - cannot access other clinic's data
|
||||
```
|
||||
|
||||
## Success Criteria Checklist
|
||||
|
||||
- [ ] All REST endpoints respond with correct HTTP status codes
|
||||
- [ ] JWT authentication works for all user roles
|
||||
- [ ] Role-based permissions enforced correctly
|
||||
- [ ] Database transactions maintain referential integrity
|
||||
- [ ] API response times < 500ms for standard operations
|
||||
- [ ] Error responses include helpful debugging information
|
||||
- [ ] Concurrent operations handle conflicts gracefully
|
||||
- [ ] All user stories validate successfully
|
||||
- [ ] No data corruption in existing KiviCare tables
|
||||
- [ ] Plugin activation/deactivation works without errors
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
1. **Plugin Activation Fails**: Verify KiviCare plugin is active first
|
||||
2. **JWT Token Invalid**: Check WordPress JWT configuration
|
||||
3. **Database Errors**: Verify WordPress database permissions
|
||||
4. **API 404 Errors**: Flush WordPress rewrite rules
|
||||
5. **CORS Issues**: Configure WordPress CORS headers for cross-origin requests
|
||||
|
||||
### Debug Mode
|
||||
```bash
|
||||
# Enable WordPress debug mode
|
||||
wp config set WP_DEBUG true
|
||||
wp config set WP_DEBUG_LOG true
|
||||
|
||||
# Check error logs
|
||||
tail -f wp-content/debug.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Quickstart Complete**: Ready for integration testing
|
||||
130
specs/001-care-api-sistema/research.md
Normal file
130
specs/001-care-api-sistema/research.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Phase 0 Research: Care API System
|
||||
|
||||
**Feature**: Care API - Sistema de gestão de cuidados de saúde
|
||||
**Date**: 2025-09-12
|
||||
**Status**: Complete
|
||||
|
||||
## Research Summary
|
||||
|
||||
All technical requirements have been clearly specified in the detailed KiviCare integration specifications provided. No unknowns remain from the Technical Context analysis.
|
||||
|
||||
## WordPress REST API Framework
|
||||
|
||||
**Decision**: WordPress REST API with custom endpoints via register_rest_route()
|
||||
**Rationale**:
|
||||
- Native WordPress integration ensuring compatibility
|
||||
- Built-in authentication and permission handling
|
||||
- Standardized HTTP methods and response formats
|
||||
- Existing KiviCare plugin integration points
|
||||
|
||||
**Alternatives considered**:
|
||||
- Custom API framework: Rejected due to WordPress ecosystem requirements
|
||||
- GraphQL implementation: Rejected due to REST API simplicity and WordPress standards
|
||||
|
||||
## JWT Authentication for WordPress
|
||||
|
||||
**Decision**: WordPress JWT Authentication plugin with custom role-based permissions
|
||||
**Rationale**:
|
||||
- Stateless authentication suitable for API consumption
|
||||
- Integrates with existing WordPress user system (wp_users)
|
||||
- Supports custom role definitions (administrator, doctor, patient, receptionist)
|
||||
- Token-based security for mobile/web app integration
|
||||
|
||||
**Alternatives considered**:
|
||||
- WordPress cookies: Rejected due to cross-origin limitations
|
||||
- OAuth2: Over-engineered for single-plugin use case
|
||||
|
||||
## KiviCare Database Integration
|
||||
|
||||
**Decision**: Direct WordPress $wpdb queries with prepared statements
|
||||
**Rationale**:
|
||||
- Preserves existing KiviCare 35-table schema integrity
|
||||
- No additional ORM layer needed - WordPress provides secure database access
|
||||
- Maintains compatibility with existing KiviCare plugin operations
|
||||
- Performance optimized for medical data relationships
|
||||
|
||||
**Alternatives considered**:
|
||||
- WordPress ORM plugins: Unnecessary complexity for established schema
|
||||
- Custom database layer: Would duplicate WordPress security features
|
||||
|
||||
## Testing Framework
|
||||
|
||||
**Decision**: PHPUnit with WordPress testing framework (WP_UnitTestCase)
|
||||
**Rationale**:
|
||||
- Standard WordPress plugin testing approach
|
||||
- Provides database setup/teardown for integration tests
|
||||
- Mocks WordPress environment for isolated testing
|
||||
- Compatible with continuous integration workflows
|
||||
|
||||
**Alternatives considered**:
|
||||
- Standalone PHPUnit: Insufficient WordPress integration
|
||||
- Custom testing framework: Reinventing established tools
|
||||
|
||||
## API Response Format
|
||||
|
||||
**Decision**: JSON responses following WordPress REST API standards
|
||||
**Rationale**:
|
||||
- Consistent with WordPress ecosystem expectations
|
||||
- Standardized error codes and response structures
|
||||
- Built-in CORS handling for cross-origin requests
|
||||
- Cacheable response formats
|
||||
|
||||
**Alternatives considered**:
|
||||
- XML responses: Outdated for modern applications
|
||||
- Custom formats: Would break client expectation standards
|
||||
|
||||
## Performance and Caching
|
||||
|
||||
**Decision**: WordPress Object Cache with Transients API
|
||||
**Rationale**:
|
||||
- Built-in WordPress caching mechanism
|
||||
- Configurable TTL for different data types
|
||||
- Compatible with Redis/Memcached backends
|
||||
- Automatic cache invalidation on data updates
|
||||
|
||||
**Alternatives considered**:
|
||||
- Database-level caching: Limited control over cache invalidation
|
||||
- External caching services: Additional infrastructure complexity
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
**Decision**: WordPress plugin with modular endpoint classes
|
||||
**Rationale**:
|
||||
- Clear separation of concerns (endpoints, models, utilities)
|
||||
- Easy testing of individual components
|
||||
- Follows WordPress plugin development best practices
|
||||
- Maintainable codebase structure
|
||||
|
||||
**Alternatives considered**:
|
||||
- Monolithic plugin file: Poor maintainability and testing
|
||||
- Multiple plugins: Unnecessary complexity for single API system
|
||||
|
||||
## Error Handling and Logging
|
||||
|
||||
**Decision**: WordPress WP_Error with structured logging to debug.log
|
||||
**Rationale**:
|
||||
- Standard WordPress error handling mechanism
|
||||
- Structured logging for operational monitoring
|
||||
- Integration with existing WordPress debugging tools
|
||||
- Configurable log levels for production/development
|
||||
|
||||
**Alternatives considered**:
|
||||
- Custom exception handling: Would bypass WordPress standards
|
||||
- External logging services: Additional infrastructure dependency
|
||||
|
||||
## Research Validation
|
||||
|
||||
✅ **All technical decisions align with**:
|
||||
- WordPress development best practices
|
||||
- Healthcare data security requirements
|
||||
- KiviCare plugin compatibility
|
||||
- Performance and scalability goals
|
||||
- Testing and observability standards
|
||||
|
||||
✅ **No remaining NEEDS CLARIFICATION items**
|
||||
✅ **All dependencies verified as available and compatible**
|
||||
✅ **Integration patterns established and documented**
|
||||
|
||||
---
|
||||
|
||||
**Phase 0 Complete**: Ready for Phase 1 Design & Contracts
|
||||
133
specs/001-care-api-sistema/spec.md
Normal file
133
specs/001-care-api-sistema/spec.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Feature Specification: Care API - Sistema de gestão de cuidados de saúde
|
||||
|
||||
**Feature Branch**: `001-care-api-sistema`
|
||||
**Created**: 2025-09-12
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Care API - Sistema de gestão de cuidados de saúde"
|
||||
|
||||
## Execution Flow (main)
|
||||
```
|
||||
1. Parse user description from Input
|
||||
→ If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
→ Identify: actors, actions, data, constraints
|
||||
3. For each unclear aspect:
|
||||
→ Mark with [NEEDS CLARIFICATION: specific question]
|
||||
4. Fill User Scenarios & Testing section
|
||||
→ If no clear user flow: ERROR "Cannot determine user scenarios"
|
||||
5. Generate Functional Requirements
|
||||
→ Each requirement must be testable
|
||||
→ Mark ambiguous requirements
|
||||
6. Identify Key Entities (if data involved)
|
||||
7. Run Review Checklist
|
||||
→ If any [NEEDS CLARIFICATION]: WARN "Spec has uncertainties"
|
||||
→ If implementation details found: ERROR "Remove tech details"
|
||||
8. Return: SUCCESS (spec ready for planning)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Guidelines
|
||||
- ✅ Focus on WHAT users need and WHY
|
||||
- ❌ Avoid HOW to implement (no tech stack, APIs, code structure)
|
||||
- 👥 Written for business stakeholders, not developers
|
||||
|
||||
### Section Requirements
|
||||
- **Mandatory sections**: Must be completed for every feature
|
||||
- **Optional sections**: Include only when relevant to the feature
|
||||
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
|
||||
|
||||
### For AI Generation
|
||||
When creating this spec from a user prompt:
|
||||
1. **Mark all ambiguities**: Use [NEEDS CLARIFICATION: specific question] for any assumption you'd need to make
|
||||
2. **Don't guess**: If the prompt doesn't specify something (e.g., "login system" without auth method), mark it
|
||||
3. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
|
||||
4. **Common underspecified areas**:
|
||||
- User types and permissions
|
||||
- Data retention/deletion policies
|
||||
- Performance targets and scale
|
||||
- Error handling behaviors
|
||||
- Integration requirements
|
||||
- Security/compliance needs
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### Primary User Story
|
||||
Healthcare professionals using KiviCare (WordPress-based clinic management system) need to access and manage comprehensive patient care data through a robust REST API. This includes managing clinic operations, patient records, appointment scheduling, medical encounters, prescriptions, and billing - all while maintaining data integrity across the existing 35-table database structure.
|
||||
|
||||
### Acceptance Scenarios
|
||||
1. **Given** a doctor is authenticated via JWT token, **When** they create a new patient via `/wp-json/kivicare/v1/patients`, **Then** the system creates entries in wp_users and wp_kc_patient_clinic_mappings tables and returns the patient ID
|
||||
2. **Given** a patient has an appointment, **When** a doctor creates an encounter via `/wp-json/kivicare/v1/encounters`, **Then** the system links it to wp_kc_patient_encounters and allows prescription creation
|
||||
3. **Given** multiple doctors from the same clinic, **When** one doctor updates a patient's medical history, **Then** other doctors with clinic access can retrieve the updated data via the patients endpoint
|
||||
4. **Given** an encounter is completed, **When** the doctor finalizes the visit, **Then** the system automatically generates billing data in wp_kc_bills with proper encounter linkage
|
||||
5. **Given** a receptionist role user, **When** they access appointment endpoints, **Then** they can manage appointments but cannot access sensitive medical data per role-based permissions
|
||||
|
||||
### Edge Cases
|
||||
- What happens when WordPress database connection fails during API calls?
|
||||
- How does system handle concurrent updates to the same appointment or encounter record?
|
||||
- What occurs when patient data needs to be exported or transferred between different clinic systems?
|
||||
- How does the API respond when KiviCare base plugin is deactivated?
|
||||
- What happens when JWT tokens expire during long-running operations?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
- **FR-001**: System MUST provide REST API endpoints for all core KiviCare operations (clinics, patients, doctors, appointments, encounters, billing)
|
||||
- **FR-002**: System MUST authenticate users via JWT tokens based on WordPress wp_users table with role-based access control
|
||||
- **FR-003**: System MUST maintain data integrity across existing KiviCare database schema (35 tables) without breaking existing WordPress plugin functionality
|
||||
- **FR-004**: System MUST support four user roles: administrator (all operations), doctor (patient care, encounters), patient (own data, appointments), receptionist (appointments, basic patient data)
|
||||
- **FR-005**: System MUST provide CRUD operations for clinics (wp_kc_clinics), patients (wp_users + mappings), and appointments (wp_kc_appointments)
|
||||
- **FR-006**: System MUST enable doctors to create patient encounters (wp_kc_patient_encounters) linked to appointments with prescription management
|
||||
- **FR-007**: System MUST generate billing records (wp_kc_bills) automatically from completed encounters with payment tracking
|
||||
- **FR-008**: System MUST provide reporting endpoints for appointments, revenue, patients, and doctor statistics
|
||||
- **FR-009**: System MUST implement proper error handling with standardized HTTP status codes and JSON error responses
|
||||
- **FR-010**: System MUST include caching mechanisms for frequently accessed data (patient encounters, appointment schedules) with configurable TTL
|
||||
- **FR-011**: System MUST verify KiviCare base plugin dependency and fail gracefully if not available
|
||||
- **FR-012**: System MUST support clinic-specific data isolation ensuring users only access data from their authorized clinics
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
- **Clinic**: Healthcare facility (wp_kc_clinics) with admin, contact details, specialties, and operational settings
|
||||
- **Patient**: Individual receiving care (wp_users + wp_kc_patient_clinic_mappings) with demographics, medical history, and clinic associations
|
||||
- **Doctor**: Healthcare provider (wp_users + wp_kc_doctor_clinic_mappings) with specialties, schedules, and clinic permissions
|
||||
- **Appointment**: Scheduled healthcare visit (wp_kc_appointments) linking patient, doctor, clinic with time slots and service mappings
|
||||
- **Encounter**: Actual medical consultation (wp_kc_patient_encounters) documenting diagnosis, treatment, and prescriptions
|
||||
- **Prescription**: Medication orders (wp_kc_prescription) linked to encounters with dosage, frequency, and instructions
|
||||
- **Bill**: Financial record (wp_kc_bills) for services rendered with payment tracking and encounter linkage
|
||||
- **Service**: Medical services offered (wp_kc_services) with pricing and availability for appointment booking
|
||||
|
||||
---
|
||||
|
||||
## Review & Acceptance Checklist
|
||||
*GATE: Automated checks run during main() execution*
|
||||
|
||||
### Content Quality
|
||||
- [x] No implementation details (languages, frameworks, APIs) - Business requirements focus maintained
|
||||
- [x] Focused on user value and business needs - Healthcare professional workflow centered
|
||||
- [x] Written for non-technical stakeholders - Clear business language used
|
||||
- [x] All mandatory sections completed - User scenarios, requirements, entities defined
|
||||
|
||||
### Requirement Completeness
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain - All ambiguities resolved with KiviCare specifications
|
||||
- [x] Requirements are testable and unambiguous - Specific API endpoints and data models defined
|
||||
- [x] Success criteria are measurable - Clear CRUD operations and role-based access defined
|
||||
- [x] Scope is clearly bounded - WordPress plugin API for existing KiviCare system
|
||||
- [x] Dependencies and assumptions identified - KiviCare base plugin dependency specified
|
||||
|
||||
---
|
||||
|
||||
## Execution Status
|
||||
*Updated by main() during processing*
|
||||
|
||||
- [x] User description parsed - KiviCare healthcare management system requirements identified
|
||||
- [x] Key concepts extracted - WordPress plugin, REST API, healthcare workflows, role-based access
|
||||
- [x] Ambiguities marked and resolved - Technical specifications provided for all unclear aspects
|
||||
- [x] User scenarios defined - Healthcare professional workflows with specific API endpoints
|
||||
- [x] Requirements generated - 12 functional requirements covering all core system capabilities
|
||||
- [x] Entities identified - 8 key data entities mapped to KiviCare database schema
|
||||
- [x] Review checklist passed - All quality and completeness criteria met
|
||||
|
||||
**SUCCESS**: Specification ready for planning phase
|
||||
|
||||
---
|
||||
209
specs/001-care-api-sistema/tasks.md
Normal file
209
specs/001-care-api-sistema/tasks.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# Tasks: Care API - Sistema de gestão de cuidados de saúde
|
||||
|
||||
**Input**: Design documents from `/specs/001-care-api-sistema/`
|
||||
**Prerequisites**: plan.md (required), research.md, data-model.md, contracts/
|
||||
|
||||
## Execution Flow (main)
|
||||
```
|
||||
1. Load plan.md from feature directory
|
||||
→ Tech stack: PHP 8.1+, WordPress 6.0+, KiviCare plugin, JWT, PHPUnit
|
||||
→ Structure: Single WordPress plugin project
|
||||
2. Load design documents:
|
||||
→ data-model.md: 8 entities (Clinic, Patient, Doctor, Appointment, Encounter, Prescription, Bill, Service)
|
||||
→ contracts/openapi.yaml: 6 endpoint groups, JWT authentication
|
||||
→ quickstart.md: 5 user story validation scenarios
|
||||
3. Generate tasks by category (62 total tasks)
|
||||
4. Apply TDD ordering: Tests before implementation
|
||||
5. Mark [P] for parallel execution (different files)
|
||||
6. SUCCESS: All contracts, entities, and user stories covered
|
||||
```
|
||||
|
||||
## Format: `[ID] [P?] Description`
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- File paths relative to repository root
|
||||
|
||||
## Path Conventions
|
||||
Single WordPress plugin project structure:
|
||||
- Plugin files: `src/` directory
|
||||
- Tests: `tests/` directory
|
||||
- WordPress integration: Standard plugin activation/hooks
|
||||
|
||||
## Phase 3.1: Setup & WordPress Plugin Foundation
|
||||
|
||||
- [ ] T001 Create WordPress plugin directory structure at `src/` with standard plugin headers and main file
|
||||
- [ ] T002 Initialize composer.json with PHPUnit, WordPress testing framework, and JWT authentication dependencies
|
||||
- [ ] T003 [P] Configure PHPUnit with WordPress testing framework in `phpunit.xml`
|
||||
- [ ] T004 [P] Set up WordPress coding standards (WPCS) and linting configuration
|
||||
- [ ] T005 Create plugin activation/deactivation hooks in `src/kivicare-api.php` with KiviCare dependency check
|
||||
- [ ] T006 Register REST API namespace '/wp-json/kivicare/v1' in `src/class-api-init.php`
|
||||
|
||||
## Phase 3.2: Tests First (TDD) ⚠️ MUST COMPLETE BEFORE 3.3
|
||||
**CRITICAL: These tests MUST be written and MUST FAIL before ANY implementation**
|
||||
|
||||
### Contract Tests (API Endpoints)
|
||||
- [ ] T007 [P] Contract test POST /wp-json/kivicare/v1/auth/login in `tests/contract/test-auth-endpoints.php`
|
||||
- [ ] T008 [P] Contract test GET /wp-json/kivicare/v1/clinics in `tests/contract/test-clinic-endpoints.php`
|
||||
- [ ] T009 [P] Contract test POST /wp-json/kivicare/v1/clinics in `tests/contract/test-clinic-endpoints.php`
|
||||
- [ ] T010 [P] Contract test GET /wp-json/kivicare/v1/patients in `tests/contract/test-patient-endpoints.php`
|
||||
- [ ] T011 [P] Contract test POST /wp-json/kivicare/v1/patients in `tests/contract/test-patient-endpoints.php`
|
||||
- [ ] T012 [P] Contract test GET /wp-json/kivicare/v1/appointments in `tests/contract/test-appointment-endpoints.php`
|
||||
- [ ] T013 [P] Contract test POST /wp-json/kivicare/v1/appointments in `tests/contract/test-appointment-endpoints.php`
|
||||
- [ ] T014 [P] Contract test GET /wp-json/kivicare/v1/encounters in `tests/contract/test-encounter-endpoints.php`
|
||||
- [ ] T015 [P] Contract test POST /wp-json/kivicare/v1/encounters in `tests/contract/test-encounter-endpoints.php`
|
||||
- [ ] T016 [P] Contract test POST /wp-json/kivicare/v1/encounters/{id}/prescriptions in `tests/contract/test-prescription-endpoints.php`
|
||||
|
||||
### Integration Tests (User Stories)
|
||||
- [ ] T017 [P] Integration test doctor creates patient record in `tests/integration/test-patient-creation-workflow.php`
|
||||
- [ ] T018 [P] Integration test doctor creates encounter with prescriptions in `tests/integration/test-encounter-workflow.php`
|
||||
- [ ] T019 [P] Integration test multi-doctor clinic data access in `tests/integration/test-clinic-data-access.php`
|
||||
- [ ] T020 [P] Integration test automatic billing generation in `tests/integration/test-billing-automation.php`
|
||||
- [ ] T021 [P] Integration test role-based access control in `tests/integration/test-role-permissions.php`
|
||||
|
||||
## Phase 3.3: Core Implementation (ONLY after tests are failing)
|
||||
|
||||
### Entity Models
|
||||
- [ ] T022 [P] Clinic model class in `src/models/class-clinic.php` with validation rules
|
||||
- [ ] T023 [P] Patient model class in `src/models/class-patient.php` with wp_users integration
|
||||
- [ ] T024 [P] Doctor model class in `src/models/class-doctor.php` with clinic mappings
|
||||
- [ ] T025 [P] Appointment model class in `src/models/class-appointment.php` with scheduling logic
|
||||
- [ ] T026 [P] Encounter model class in `src/models/class-encounter.php` with appointment linkage
|
||||
- [ ] T027 [P] Prescription model class in `src/models/class-prescription.php` with encounter linkage
|
||||
- [ ] T028 [P] Bill model class in `src/models/class-bill.php` with payment tracking
|
||||
- [ ] T029 [P] Service model class in `src/models/class-service.php` with pricing data
|
||||
|
||||
### Authentication & Authorization Service
|
||||
- [ ] T030 JWT authentication service in `src/services/class-jwt-auth.php`
|
||||
- [ ] T031 Role-based permission service in `src/services/class-role-permissions.php`
|
||||
- [ ] T032 User session management in `src/services/class-session-manager.php`
|
||||
|
||||
### Database Services
|
||||
- [ ] T033 [P] Clinic database service in `src/services/class-clinic-service.php`
|
||||
- [ ] T034 [P] Patient database service in `src/services/class-patient-service.php`
|
||||
- [ ] T035 [P] Doctor database service in `src/services/class-doctor-service.php`
|
||||
- [ ] T036 [P] Appointment database service in `src/services/class-appointment-service.php`
|
||||
- [ ] T037 [P] Encounter database service in `src/services/class-encounter-service.php`
|
||||
- [ ] T038 [P] Prescription database service in `src/services/class-prescription-service.php`
|
||||
- [ ] T039 [P] Bill database service in `src/services/class-bill-service.php`
|
||||
|
||||
### REST API Endpoints
|
||||
- [ ] T040 Authentication endpoints in `src/endpoints/class-auth-endpoints.php`
|
||||
- [ ] T041 Clinic CRUD endpoints in `src/endpoints/class-clinic-endpoints.php`
|
||||
- [ ] T042 Patient CRUD endpoints in `src/endpoints/class-patient-endpoints.php`
|
||||
- [ ] T043 Appointment CRUD endpoints in `src/endpoints/class-appointment-endpoints.php`
|
||||
- [ ] T044 Encounter CRUD endpoints in `src/endpoints/class-encounter-endpoints.php`
|
||||
- [ ] T045 Prescription endpoints in `src/endpoints/class-prescription-endpoints.php`
|
||||
|
||||
### Validation & Error Handling
|
||||
- [ ] T046 Input validation service in `src/utils/class-input-validator.php`
|
||||
- [ ] T047 Error response formatter in `src/utils/class-error-handler.php`
|
||||
- [ ] T048 Request/response logging in `src/utils/class-api-logger.php`
|
||||
|
||||
## Phase 3.4: Integration & Middleware
|
||||
|
||||
- [ ] T049 Connect all database services to WordPress $wpdb with prepared statements
|
||||
- [ ] T050 Implement JWT middleware for all protected endpoints
|
||||
- [ ] T051 Add clinic isolation middleware for multi-clinic data security
|
||||
- [ ] T052 WordPress user role integration with KiviCare roles
|
||||
- [ ] T053 Add structured error responses with proper HTTP status codes
|
||||
- [ ] T054 Implement request/response logging with WordPress debug.log integration
|
||||
|
||||
## Phase 3.5: Caching & Performance
|
||||
|
||||
- [ ] T055 WordPress Object Cache implementation for patient encounters in `src/services/class-cache-manager.php`
|
||||
- [ ] T056 Cache invalidation on data updates for appointment schedules
|
||||
- [ ] T057 Database query optimization with proper indexes
|
||||
- [ ] T058 API response time monitoring and performance logging
|
||||
|
||||
## Phase 3.6: Polish & Documentation
|
||||
|
||||
- [ ] T059 [P] Unit tests for all validation rules in `tests/unit/test-input-validation.php`
|
||||
- [ ] T060 [P] Unit tests for model classes in `tests/unit/test-models.php`
|
||||
- [ ] T061 [P] Performance tests ensuring <500ms response times in `tests/performance/test-api-performance.php`
|
||||
- [ ] T062 Execute quickstart.md validation scenarios and fix any issues
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Setup Phase (T001-T006)**:
|
||||
- T001 blocks all other tasks (plugin structure required)
|
||||
- T002-T006 can run in parallel after T001
|
||||
|
||||
**Tests Phase (T007-T021)**:
|
||||
- Must complete before any implementation (TDD requirement)
|
||||
- All contract tests (T007-T016) can run in parallel
|
||||
- All integration tests (T017-T021) can run in parallel
|
||||
|
||||
**Core Implementation (T022-T048)**:
|
||||
- T022-T029 (models) can run in parallel after tests
|
||||
- T030-T032 (auth) sequential (shared authentication state)
|
||||
- T033-T039 (services) can run in parallel, depend on models
|
||||
- T040-T045 (endpoints) sequential (shared REST namespace registration)
|
||||
- T046-T048 (utils) can run in parallel
|
||||
|
||||
**Integration Phase (T049-T054)**:
|
||||
- T049 blocks T050-T054 (database connection required)
|
||||
- T050-T054 can run in parallel after T049
|
||||
|
||||
**Performance & Polish (T055-T062)**:
|
||||
- T055-T058 can run in parallel
|
||||
- T059-T061 can run in parallel (different test files)
|
||||
- T062 must be last (validation requires complete system)
|
||||
|
||||
## Parallel Example
|
||||
|
||||
```bash
|
||||
# Launch contract tests together (Phase 3.2):
|
||||
Task: "Contract test POST /wp-json/kivicare/v1/auth/login in tests/contract/test-auth-endpoints.php"
|
||||
Task: "Contract test GET /wp-json/kivicare/v1/clinics in tests/contract/test-clinic-endpoints.php"
|
||||
Task: "Contract test GET /wp-json/kivicare/v1/patients in tests/contract/test-patient-endpoints.php"
|
||||
Task: "Contract test GET /wp-json/kivicare/v1/appointments in tests/contract/test-appointment-endpoints.php"
|
||||
|
||||
# Launch model creation together (Phase 3.3):
|
||||
Task: "Clinic model class in src/models/class-clinic.php with validation rules"
|
||||
Task: "Patient model class in src/models/class-patient.php with wp_users integration"
|
||||
Task: "Doctor model class in src/models/class-doctor.php with clinic mappings"
|
||||
Task: "Appointment model class in src/models/class-appointment.php with scheduling logic"
|
||||
```
|
||||
|
||||
## WordPress-Specific Notes
|
||||
|
||||
- Follow WordPress coding standards (WPCS) for all PHP code
|
||||
- Use WordPress hooks and filters for extensibility
|
||||
- Implement proper capability checks for each endpoint
|
||||
- Use WordPress nonce verification where appropriate
|
||||
- Ensure compatibility with WordPress multisite installations
|
||||
- All database operations must use prepared statements via $wpdb
|
||||
- Plugin must gracefully handle KiviCare plugin deactivation
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- [x] All 6 endpoint groups have contract tests (T007-T016)
|
||||
- [x] All 8 entities have model tasks (T022-T029)
|
||||
- [x] All 5 user stories have integration tests (T017-T021)
|
||||
- [x] Tests come before implementation (Phase 3.2 → 3.3)
|
||||
- [x] Parallel tasks are truly independent (different files)
|
||||
- [x] Each task specifies exact file path
|
||||
- [x] WordPress plugin structure properly planned
|
||||
- [x] KiviCare database schema integration covered
|
||||
- [x] JWT authentication and role-based permissions included
|
||||
- [x] Performance and caching requirements addressed
|
||||
|
||||
## WordPress Development Commands
|
||||
|
||||
```bash
|
||||
# Plugin development
|
||||
wp plugin activate kivicare-api
|
||||
wp plugin deactivate kivicare-api
|
||||
|
||||
# Testing
|
||||
vendor/bin/phpunit tests/
|
||||
wp db query "SELECT * FROM wp_kc_clinics LIMIT 5"
|
||||
|
||||
# Debugging
|
||||
wp config set WP_DEBUG true
|
||||
wp config set WP_DEBUG_LOG true
|
||||
tail -f wp-content/debug.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Task Generation Complete**: 62 tasks ready for WordPress TDD implementation
|
||||
339
src/includes/class-api-init.php
Normal file
339
src/includes/class-api-init.php
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* KiviCare API Initialization
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
// Exit if accessed directly.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main API initialization class.
|
||||
*
|
||||
* @class KiviCare_API_Init
|
||||
*/
|
||||
class KiviCare_API_Init {
|
||||
|
||||
/**
|
||||
* The single instance of the class.
|
||||
*
|
||||
* @var KiviCare_API_Init
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected static $_instance = null;
|
||||
|
||||
/**
|
||||
* REST API namespace.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const API_NAMESPACE = 'kivicare/v1';
|
||||
|
||||
/**
|
||||
* Main KiviCare_API_Init Instance.
|
||||
*
|
||||
* Ensures only one instance of KiviCare_API_Init is loaded or can be loaded.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @static
|
||||
* @return KiviCare_API_Init - Main instance.
|
||||
*/
|
||||
public static function instance() {
|
||||
if ( is_null( self::$_instance ) ) {
|
||||
self::$_instance = new self();
|
||||
}
|
||||
return self::$_instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* KiviCare_API_Init Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->init_hooks();
|
||||
$this->includes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook into actions and filters.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function init_hooks() {
|
||||
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
|
||||
add_action( 'init', array( $this, 'check_dependencies' ) );
|
||||
add_filter( 'rest_pre_serve_request', array( $this, 'rest_pre_serve_request' ), 10, 4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Include required core files.
|
||||
*/
|
||||
public function includes() {
|
||||
// Base classes will be included here as they are created
|
||||
// include_once KIVICARE_API_ABSPATH . 'services/class-jwt-auth.php';
|
||||
// include_once KIVICARE_API_ABSPATH . 'endpoints/class-auth-endpoints.php';
|
||||
// etc.
|
||||
}
|
||||
|
||||
/**
|
||||
* Check plugin dependencies.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function check_dependencies() {
|
||||
// Check if KiviCare plugin is active
|
||||
if ( ! $this->is_kivicare_active() ) {
|
||||
add_action( 'admin_notices', array( $this, 'kivicare_dependency_notice' ) );
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check required database tables
|
||||
if ( ! $this->check_kivicare_tables() ) {
|
||||
add_action( 'admin_notices', array( $this, 'database_tables_notice' ) );
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if KiviCare plugin is active.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function is_kivicare_active() {
|
||||
return is_plugin_active( 'kivicare-clinic-&-patient-management-system/kivicare-clinic-&-patient-management-system.php' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if required KiviCare database tables exist.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function check_kivicare_tables() {
|
||||
global $wpdb;
|
||||
|
||||
$required_tables = array(
|
||||
'kc_clinics',
|
||||
'kc_appointments',
|
||||
'kc_patient_encounters',
|
||||
'kc_prescription',
|
||||
'kc_bills',
|
||||
'kc_services',
|
||||
'kc_doctor_clinic_mappings',
|
||||
'kc_patient_clinic_mappings',
|
||||
);
|
||||
|
||||
foreach ( $required_tables as $table ) {
|
||||
$table_name = $wpdb->prefix . $table;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$table_exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $table_name ) );
|
||||
|
||||
if ( $table_name !== $table_exists ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display admin notice for KiviCare dependency.
|
||||
*/
|
||||
public function kivicare_dependency_notice() {
|
||||
?>
|
||||
<div class="notice notice-error">
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'KiviCare API Error:', 'kivicare-api' ); ?></strong>
|
||||
<?php esc_html_e( 'KiviCare Plugin is required for KiviCare API to work properly.', 'kivicare-api' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Display admin notice for missing database tables.
|
||||
*/
|
||||
public function database_tables_notice() {
|
||||
?>
|
||||
<div class="notice notice-error">
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'KiviCare API Error:', 'kivicare-api' ); ?></strong>
|
||||
<?php esc_html_e( 'Required KiviCare database tables are missing. Please ensure KiviCare plugin is properly activated.', 'kivicare-api' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST API routes.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function register_rest_routes() {
|
||||
// Only register routes if dependencies are met
|
||||
if ( ! $this->check_dependencies() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow plugins to hook into REST API registration.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
do_action( 'kivicare_api_register_rest_routes' );
|
||||
|
||||
// Register a test endpoint to verify API is working
|
||||
register_rest_route(
|
||||
self::API_NAMESPACE,
|
||||
'/status',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_api_status' ),
|
||||
'permission_callback' => array( $this, 'check_api_permissions' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API status endpoint.
|
||||
*
|
||||
* @param WP_REST_Request $request Request object.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function get_api_status( $request ) {
|
||||
global $wpdb;
|
||||
|
||||
// Get basic KiviCare database stats
|
||||
$clinic_count = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}kc_clinics WHERE status = 1" );
|
||||
$patient_count = $wpdb->get_var(
|
||||
"SELECT COUNT(DISTINCT u.ID) FROM {$wpdb->users} u
|
||||
INNER JOIN {$wpdb->usermeta} um ON u.ID = um.user_id
|
||||
WHERE um.meta_key = '{$wpdb->prefix}capabilities'
|
||||
AND um.meta_value LIKE '%patient%'"
|
||||
);
|
||||
|
||||
$response_data = array(
|
||||
'status' => 'active',
|
||||
'version' => KIVICARE_API_VERSION,
|
||||
'namespace' => self::API_NAMESPACE,
|
||||
'timestamp' => current_time( 'mysql' ),
|
||||
'wordpress_version' => get_bloginfo( 'version' ),
|
||||
'php_version' => phpversion(),
|
||||
'kivicare_active' => $this->is_kivicare_active(),
|
||||
'statistics' => array(
|
||||
'active_clinics' => (int) $clinic_count,
|
||||
'total_patients' => (int) $patient_count,
|
||||
),
|
||||
'endpoints' => $this->get_available_endpoints(),
|
||||
);
|
||||
|
||||
return rest_ensure_response( $response_data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available API endpoints.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function get_available_endpoints() {
|
||||
return array(
|
||||
'authentication' => array(
|
||||
'POST /auth/login',
|
||||
'POST /auth/refresh',
|
||||
'POST /auth/logout',
|
||||
),
|
||||
'clinics' => array(
|
||||
'GET /clinics',
|
||||
'POST /clinics',
|
||||
'GET /clinics/{id}',
|
||||
'PUT /clinics/{id}',
|
||||
'DELETE /clinics/{id}',
|
||||
),
|
||||
'patients' => array(
|
||||
'GET /patients',
|
||||
'POST /patients',
|
||||
'GET /patients/{id}',
|
||||
'PUT /patients/{id}',
|
||||
'GET /patients/{id}/encounters',
|
||||
),
|
||||
'appointments' => array(
|
||||
'GET /appointments',
|
||||
'POST /appointments',
|
||||
'GET /appointments/{id}',
|
||||
'PUT /appointments/{id}',
|
||||
'DELETE /appointments/{id}',
|
||||
),
|
||||
'encounters' => array(
|
||||
'GET /encounters',
|
||||
'POST /encounters',
|
||||
'GET /encounters/{id}',
|
||||
'PUT /encounters/{id}',
|
||||
'POST /encounters/{id}/prescriptions',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check API permissions.
|
||||
*
|
||||
* @param WP_REST_Request $request Request object.
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
public function check_api_permissions( $request ) {
|
||||
// For status endpoint, allow if user can manage options or has API access
|
||||
if ( current_user_can( 'manage_options' ) || current_user_can( 'manage_kivicare_api' ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow unauthenticated access to status endpoint for basic health checks
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify REST API response headers.
|
||||
*
|
||||
* @param bool $served Whether the request has already been served.
|
||||
* @param WP_HTTP_Response $result Result to send to the client.
|
||||
* @param WP_REST_Request $request Request used to generate the response.
|
||||
* @param WP_REST_Server $server Server instance.
|
||||
* @return bool
|
||||
*/
|
||||
public function rest_pre_serve_request( $served, $result, $request, $server ) {
|
||||
// Only modify responses for our API namespace
|
||||
$route = $request->get_route();
|
||||
if ( strpos( $route, '/' . self::API_NAMESPACE . '/' ) !== 0 ) {
|
||||
return $served;
|
||||
}
|
||||
|
||||
// Add custom headers
|
||||
$result->header( 'X-KiviCare-API-Version', KIVICARE_API_VERSION );
|
||||
$result->header( 'X-Powered-By', 'KiviCare API by Descomplicar®' );
|
||||
|
||||
// Add CORS headers for development
|
||||
if ( defined( 'KIVICARE_API_DEBUG' ) && KIVICARE_API_DEBUG ) {
|
||||
$result->header( 'Access-Control-Allow-Origin', '*' );
|
||||
$result->header( 'Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS' );
|
||||
$result->header( 'Access-Control-Allow-Headers', 'Authorization, Content-Type, X-WP-Nonce' );
|
||||
}
|
||||
|
||||
return $served;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API namespace.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_namespace() {
|
||||
return self::API_NAMESPACE;
|
||||
}
|
||||
}
|
||||
1045
src/includes/models/class-appointment.php
Normal file
1045
src/includes/models/class-appointment.php
Normal file
File diff suppressed because it is too large
Load Diff
843
src/includes/models/class-bill.php
Normal file
843
src/includes/models/class-bill.php
Normal file
@@ -0,0 +1,843 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Bill Model
|
||||
*
|
||||
* Handles billing operations and payment management
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Models
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Models;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Bill
|
||||
*
|
||||
* Model for handling billing, invoices and payment management
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Bill {
|
||||
|
||||
/**
|
||||
* Database table name
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private static $table_name = 'kc_bills';
|
||||
|
||||
/**
|
||||
* Bill ID
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $id;
|
||||
|
||||
/**
|
||||
* Bill data
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $data = array();
|
||||
|
||||
/**
|
||||
* Required fields for bill creation
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $required_fields = array(
|
||||
'title',
|
||||
'total_amount',
|
||||
'clinic_id'
|
||||
);
|
||||
|
||||
/**
|
||||
* Valid payment statuses
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $valid_payment_statuses = array(
|
||||
'pending' => 'Pending',
|
||||
'paid' => 'Paid',
|
||||
'partial' => 'Partially Paid',
|
||||
'overdue' => 'Overdue',
|
||||
'cancelled' => 'Cancelled'
|
||||
);
|
||||
|
||||
/**
|
||||
* Valid bill statuses
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $valid_statuses = array(
|
||||
1 => 'active',
|
||||
0 => 'inactive'
|
||||
);
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param int|array $bill_id_or_data Bill ID or data array
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct( $bill_id_or_data = null ) {
|
||||
if ( is_numeric( $bill_id_or_data ) ) {
|
||||
$this->id = (int) $bill_id_or_data;
|
||||
$this->load_data();
|
||||
} elseif ( is_array( $bill_id_or_data ) ) {
|
||||
$this->data = $bill_id_or_data;
|
||||
$this->id = isset( $this->data['id'] ) ? (int) $this->data['id'] : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load bill data from database
|
||||
*
|
||||
* @return bool True on success, false on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function load_data() {
|
||||
if ( ! $this->id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$bill_data = self::get_bill_full_data( $this->id );
|
||||
|
||||
if ( $bill_data ) {
|
||||
$this->data = $bill_data;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new bill
|
||||
*
|
||||
* @param array $bill_data Bill data
|
||||
* @return int|WP_Error Bill ID on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function create( $bill_data ) {
|
||||
// Validate required fields
|
||||
$validation = self::validate_bill_data( $bill_data );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
// Calculate actual amount (total - discount)
|
||||
$total_amount = (float) $bill_data['total_amount'];
|
||||
$discount = isset( $bill_data['discount'] ) ? (float) $bill_data['discount'] : 0;
|
||||
$actual_amount = $total_amount - $discount;
|
||||
|
||||
// Prepare data for insertion
|
||||
$insert_data = array(
|
||||
'encounter_id' => isset( $bill_data['encounter_id'] ) ? (int) $bill_data['encounter_id'] : null,
|
||||
'appointment_id' => isset( $bill_data['appointment_id'] ) ? (int) $bill_data['appointment_id'] : null,
|
||||
'title' => sanitize_text_field( $bill_data['title'] ),
|
||||
'total_amount' => number_format( $total_amount, 2, '.', '' ),
|
||||
'discount' => number_format( $discount, 2, '.', '' ),
|
||||
'actual_amount' => number_format( $actual_amount, 2, '.', '' ),
|
||||
'status' => isset( $bill_data['status'] ) ? (int) $bill_data['status'] : 1,
|
||||
'payment_status' => isset( $bill_data['payment_status'] ) ? sanitize_text_field( $bill_data['payment_status'] ) : 'pending',
|
||||
'clinic_id' => (int) $bill_data['clinic_id'],
|
||||
'created_at' => current_time( 'mysql' )
|
||||
);
|
||||
|
||||
$result = $wpdb->insert( $table, $insert_data );
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'bill_creation_failed',
|
||||
'Failed to create bill: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return $wpdb->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update bill data
|
||||
*
|
||||
* @param int $bill_id Bill ID
|
||||
* @param array $bill_data Updated bill data
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function update( $bill_id, $bill_data ) {
|
||||
if ( ! self::exists( $bill_id ) ) {
|
||||
return new \WP_Error(
|
||||
'bill_not_found',
|
||||
'Bill not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
// Prepare update data
|
||||
$update_data = array();
|
||||
$allowed_fields = array(
|
||||
'title', 'total_amount', 'discount', 'status', 'payment_status'
|
||||
);
|
||||
|
||||
foreach ( $allowed_fields as $field ) {
|
||||
if ( isset( $bill_data[ $field ] ) ) {
|
||||
$value = $bill_data[ $field ];
|
||||
|
||||
switch ( $field ) {
|
||||
case 'total_amount':
|
||||
case 'discount':
|
||||
$update_data[ $field ] = number_format( (float) $value, 2, '.', '' );
|
||||
break;
|
||||
case 'status':
|
||||
$update_data[ $field ] = (int) $value;
|
||||
break;
|
||||
case 'payment_status':
|
||||
if ( in_array( $value, array_keys( self::$valid_payment_statuses ) ) ) {
|
||||
$update_data[ $field ] = sanitize_text_field( $value );
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$update_data[ $field ] = sanitize_text_field( $value );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate actual amount if total or discount changed
|
||||
if ( isset( $update_data['total_amount'] ) || isset( $update_data['discount'] ) ) {
|
||||
$current_bill = self::get_by_id( $bill_id );
|
||||
|
||||
$new_total = isset( $update_data['total_amount'] ) ?
|
||||
(float) $update_data['total_amount'] :
|
||||
(float) $current_bill['total_amount'];
|
||||
|
||||
$new_discount = isset( $update_data['discount'] ) ?
|
||||
(float) $update_data['discount'] :
|
||||
(float) $current_bill['discount'];
|
||||
|
||||
$update_data['actual_amount'] = number_format( $new_total - $new_discount, 2, '.', '' );
|
||||
}
|
||||
|
||||
if ( empty( $update_data ) ) {
|
||||
return new \WP_Error(
|
||||
'no_data_to_update',
|
||||
'No valid data provided for update',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$result = $wpdb->update(
|
||||
$table,
|
||||
$update_data,
|
||||
array( 'id' => $bill_id ),
|
||||
null,
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'bill_update_failed',
|
||||
'Failed to update bill: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a bill
|
||||
*
|
||||
* @param int $bill_id Bill ID
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function delete( $bill_id ) {
|
||||
if ( ! self::exists( $bill_id ) ) {
|
||||
return new \WP_Error(
|
||||
'bill_not_found',
|
||||
'Bill not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check if bill is paid - might want to prevent deletion
|
||||
$bill = self::get_by_id( $bill_id );
|
||||
if ( $bill['payment_status'] === 'paid' ) {
|
||||
return new \WP_Error(
|
||||
'cannot_delete_paid_bill',
|
||||
'Cannot delete a paid bill',
|
||||
array( 'status' => 409 )
|
||||
);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$result = $wpdb->delete(
|
||||
$table,
|
||||
array( 'id' => $bill_id ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'bill_deletion_failed',
|
||||
'Failed to delete bill: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bill by ID
|
||||
*
|
||||
* @param int $bill_id Bill ID
|
||||
* @return array|null Bill data or null if not found
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_by_id( $bill_id ) {
|
||||
return self::get_bill_full_data( $bill_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bills with optional filtering
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return array Array of bill data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_all( $args = array() ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$defaults = array(
|
||||
'clinic_id' => null,
|
||||
'encounter_id' => null,
|
||||
'appointment_id' => null,
|
||||
'status' => null,
|
||||
'payment_status' => null,
|
||||
'date_from' => null,
|
||||
'date_to' => null,
|
||||
'search' => '',
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
'orderby' => 'created_at',
|
||||
'order' => 'DESC'
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$where_clauses = array( '1=1' );
|
||||
$where_values = array();
|
||||
|
||||
// Clinic filter
|
||||
if ( ! is_null( $args['clinic_id'] ) ) {
|
||||
$where_clauses[] = 'b.clinic_id = %d';
|
||||
$where_values[] = $args['clinic_id'];
|
||||
}
|
||||
|
||||
// Encounter filter
|
||||
if ( ! is_null( $args['encounter_id'] ) ) {
|
||||
$where_clauses[] = 'b.encounter_id = %d';
|
||||
$where_values[] = $args['encounter_id'];
|
||||
}
|
||||
|
||||
// Appointment filter
|
||||
if ( ! is_null( $args['appointment_id'] ) ) {
|
||||
$where_clauses[] = 'b.appointment_id = %d';
|
||||
$where_values[] = $args['appointment_id'];
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ( ! is_null( $args['status'] ) ) {
|
||||
$where_clauses[] = 'b.status = %d';
|
||||
$where_values[] = $args['status'];
|
||||
}
|
||||
|
||||
// Payment status filter
|
||||
if ( ! is_null( $args['payment_status'] ) ) {
|
||||
$where_clauses[] = 'b.payment_status = %s';
|
||||
$where_values[] = $args['payment_status'];
|
||||
}
|
||||
|
||||
// Date range filters
|
||||
if ( ! is_null( $args['date_from'] ) ) {
|
||||
$where_clauses[] = 'DATE(b.created_at) >= %s';
|
||||
$where_values[] = $args['date_from'];
|
||||
}
|
||||
|
||||
if ( ! is_null( $args['date_to'] ) ) {
|
||||
$where_clauses[] = 'DATE(b.created_at) <= %s';
|
||||
$where_values[] = $args['date_to'];
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if ( ! empty( $args['search'] ) ) {
|
||||
$where_clauses[] = '(b.title LIKE %s OR p.first_name LIKE %s OR p.last_name LIKE %s OR c.name LIKE %s)';
|
||||
$search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%';
|
||||
$where_values = array_merge( $where_values, array_fill( 0, 4, $search_term ) );
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
// Build query
|
||||
$query = "SELECT b.*,
|
||||
c.name as clinic_name,
|
||||
CONCAT(p.first_name, ' ', p.last_name) as patient_name,
|
||||
p.user_email as patient_email,
|
||||
e.encounter_date,
|
||||
a.appointment_start_date
|
||||
FROM {$table} b
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON b.clinic_id = c.id
|
||||
LEFT JOIN {$wpdb->prefix}kc_patient_encounters e ON b.encounter_id = e.id
|
||||
LEFT JOIN {$wpdb->prefix}users p ON e.patient_id = p.ID
|
||||
LEFT JOIN {$wpdb->prefix}kc_appointments a ON b.appointment_id = a.id
|
||||
WHERE {$where_sql}";
|
||||
|
||||
$query .= sprintf( ' ORDER BY b.%s %s',
|
||||
sanitize_sql_orderby( $args['orderby'] ),
|
||||
sanitize_sql_orderby( $args['order'] )
|
||||
);
|
||||
|
||||
$query .= $wpdb->prepare( ' LIMIT %d OFFSET %d', $args['limit'], $args['offset'] );
|
||||
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
|
||||
$bills = $wpdb->get_results( $query, ARRAY_A );
|
||||
|
||||
return array_map( array( self::class, 'format_bill_data' ), $bills );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bill full data with related entities
|
||||
*
|
||||
* @param int $bill_id Bill ID
|
||||
* @return array|null Full bill data or null if not found
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_bill_full_data( $bill_id ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$bill = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT b.*,
|
||||
c.name as clinic_name, c.address as clinic_address,
|
||||
CONCAT(p.first_name, ' ', p.last_name) as patient_name,
|
||||
p.user_email as patient_email,
|
||||
e.encounter_date,
|
||||
CONCAT(d.first_name, ' ', d.last_name) as doctor_name,
|
||||
a.appointment_start_date, a.appointment_start_time
|
||||
FROM {$table} b
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON b.clinic_id = c.id
|
||||
LEFT JOIN {$wpdb->prefix}kc_patient_encounters e ON b.encounter_id = e.id
|
||||
LEFT JOIN {$wpdb->prefix}users p ON e.patient_id = p.ID
|
||||
LEFT JOIN {$wpdb->prefix}users d ON e.doctor_id = d.ID
|
||||
LEFT JOIN {$wpdb->prefix}kc_appointments a ON b.appointment_id = a.id
|
||||
WHERE b.id = %d",
|
||||
$bill_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! $bill ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::format_bill_data( $bill );
|
||||
}
|
||||
|
||||
/**
|
||||
* Process payment for bill
|
||||
*
|
||||
* @param int $bill_id Bill ID
|
||||
* @param array $payment_data Payment information
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function process_payment( $bill_id, $payment_data ) {
|
||||
if ( ! self::exists( $bill_id ) ) {
|
||||
return new \WP_Error(
|
||||
'bill_not_found',
|
||||
'Bill not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
$bill = self::get_by_id( $bill_id );
|
||||
|
||||
// Validate payment amount
|
||||
$payment_amount = (float) $payment_data['amount'];
|
||||
$bill_amount = (float) $bill['actual_amount'];
|
||||
|
||||
if ( $payment_amount <= 0 ) {
|
||||
return new \WP_Error(
|
||||
'invalid_payment_amount',
|
||||
'Payment amount must be greater than zero',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
if ( $payment_amount > $bill_amount ) {
|
||||
return new \WP_Error(
|
||||
'payment_exceeds_bill',
|
||||
'Payment amount exceeds bill amount',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Determine new payment status
|
||||
$new_status = 'paid';
|
||||
if ( $payment_amount < $bill_amount ) {
|
||||
$new_status = 'partial';
|
||||
}
|
||||
|
||||
// Update bill payment status
|
||||
$result = self::update( $bill_id, array(
|
||||
'payment_status' => $new_status
|
||||
) );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Log payment (could be extended to create payment records)
|
||||
do_action( 'kivicare_payment_processed', $bill_id, $payment_data );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overdue bills
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return array Array of overdue bills
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_overdue_bills( $args = array() ) {
|
||||
$defaults = array(
|
||||
'days_overdue' => 30,
|
||||
'clinic_id' => null,
|
||||
'limit' => 50,
|
||||
'offset' => 0
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$where_clauses = array(
|
||||
'payment_status IN ("pending", "partial")',
|
||||
'status = 1',
|
||||
'DATEDIFF(CURDATE(), created_at) > %d'
|
||||
);
|
||||
$where_values = array( $args['days_overdue'] );
|
||||
|
||||
if ( ! is_null( $args['clinic_id'] ) ) {
|
||||
$where_clauses[] = 'clinic_id = %d';
|
||||
$where_values[] = $args['clinic_id'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$bills = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT *, DATEDIFF(CURDATE(), created_at) as days_overdue
|
||||
FROM {$table}
|
||||
WHERE {$where_sql}
|
||||
ORDER BY created_at ASC
|
||||
LIMIT %d OFFSET %d",
|
||||
array_merge( $where_values, array( $args['limit'], $args['offset'] ) )
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
// Update overdue bills status
|
||||
foreach ( $bills as &$bill ) {
|
||||
if ( $bill['payment_status'] !== 'overdue' ) {
|
||||
self::update( $bill['id'], array( 'payment_status' => 'overdue' ) );
|
||||
$bill['payment_status'] = 'overdue';
|
||||
}
|
||||
}
|
||||
|
||||
return array_map( array( self::class, 'format_bill_data' ), $bills );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if bill exists
|
||||
*
|
||||
* @param int $bill_id Bill ID
|
||||
* @return bool True if exists, false otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function exists( $bill_id ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE id = %d",
|
||||
$bill_id
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate bill data
|
||||
*
|
||||
* @param array $bill_data Bill data to validate
|
||||
* @return bool|WP_Error True if valid, WP_Error if invalid
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_bill_data( $bill_data ) {
|
||||
$errors = array();
|
||||
|
||||
// Check required fields
|
||||
foreach ( self::$required_fields as $field ) {
|
||||
if ( empty( $bill_data[ $field ] ) ) {
|
||||
$errors[] = "Field '{$field}' is required";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate amounts
|
||||
if ( isset( $bill_data['total_amount'] ) ) {
|
||||
if ( ! is_numeric( $bill_data['total_amount'] ) || (float) $bill_data['total_amount'] <= 0 ) {
|
||||
$errors[] = 'Total amount must be a positive number';
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( $bill_data['discount'] ) ) {
|
||||
if ( ! is_numeric( $bill_data['discount'] ) || (float) $bill_data['discount'] < 0 ) {
|
||||
$errors[] = 'Discount must be a non-negative number';
|
||||
}
|
||||
|
||||
// Check if discount doesn't exceed total
|
||||
if ( isset( $bill_data['total_amount'] ) &&
|
||||
(float) $bill_data['discount'] > (float) $bill_data['total_amount'] ) {
|
||||
$errors[] = 'Discount cannot exceed total amount';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate payment status
|
||||
if ( isset( $bill_data['payment_status'] ) &&
|
||||
! array_key_exists( $bill_data['payment_status'], self::$valid_payment_statuses ) ) {
|
||||
$errors[] = 'Invalid payment status';
|
||||
}
|
||||
|
||||
// Validate entities exist
|
||||
if ( ! empty( $bill_data['clinic_id'] ) &&
|
||||
! Clinic::exists( $bill_data['clinic_id'] ) ) {
|
||||
$errors[] = 'Clinic not found';
|
||||
}
|
||||
|
||||
if ( ! empty( $bill_data['encounter_id'] ) &&
|
||||
! Encounter::exists( $bill_data['encounter_id'] ) ) {
|
||||
$errors[] = 'Encounter not found';
|
||||
}
|
||||
|
||||
if ( ! empty( $bill_data['appointment_id'] ) &&
|
||||
! Appointment::exists( $bill_data['appointment_id'] ) ) {
|
||||
$errors[] = 'Appointment not found';
|
||||
}
|
||||
|
||||
// Validate status
|
||||
if ( isset( $bill_data['status'] ) &&
|
||||
! array_key_exists( $bill_data['status'], self::$valid_statuses ) ) {
|
||||
$errors[] = 'Invalid status value';
|
||||
}
|
||||
|
||||
if ( ! empty( $errors ) ) {
|
||||
return new \WP_Error(
|
||||
'bill_validation_failed',
|
||||
'Bill validation failed',
|
||||
array(
|
||||
'status' => 400,
|
||||
'errors' => $errors
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bill data for API response
|
||||
*
|
||||
* @param array $bill_data Raw bill data
|
||||
* @return array Formatted bill data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function format_bill_data( $bill_data ) {
|
||||
if ( ! $bill_data ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$formatted = array(
|
||||
'id' => (int) $bill_data['id'],
|
||||
'title' => $bill_data['title'],
|
||||
'total_amount' => (float) $bill_data['total_amount'],
|
||||
'discount' => (float) $bill_data['discount'],
|
||||
'actual_amount' => (float) $bill_data['actual_amount'],
|
||||
'status' => (int) $bill_data['status'],
|
||||
'status_text' => self::$valid_statuses[ $bill_data['status'] ] ?? 'unknown',
|
||||
'payment_status' => $bill_data['payment_status'],
|
||||
'payment_status_text' => self::$valid_payment_statuses[ $bill_data['payment_status'] ] ?? 'Unknown',
|
||||
'created_at' => $bill_data['created_at'],
|
||||
'clinic' => array(
|
||||
'id' => (int) $bill_data['clinic_id'],
|
||||
'name' => $bill_data['clinic_name'] ?? '',
|
||||
'address' => $bill_data['clinic_address'] ?? ''
|
||||
),
|
||||
'encounter_id' => isset( $bill_data['encounter_id'] ) ? (int) $bill_data['encounter_id'] : null,
|
||||
'appointment_id' => isset( $bill_data['appointment_id'] ) ? (int) $bill_data['appointment_id'] : null,
|
||||
'patient' => array(
|
||||
'name' => $bill_data['patient_name'] ?? '',
|
||||
'email' => $bill_data['patient_email'] ?? ''
|
||||
),
|
||||
'doctor_name' => $bill_data['doctor_name'] ?? '',
|
||||
'encounter_date' => $bill_data['encounter_date'] ?? null,
|
||||
'appointment_date' => $bill_data['appointment_start_date'] ?? null,
|
||||
'appointment_time' => $bill_data['appointment_start_time'] ?? null,
|
||||
'days_overdue' => isset( $bill_data['days_overdue'] ) ? (int) $bill_data['days_overdue'] : null
|
||||
);
|
||||
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bill statistics
|
||||
*
|
||||
* @param array $filters Optional filters
|
||||
* @return array Bill statistics
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_statistics( $filters = array() ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$where_clauses = array( 'status = 1' );
|
||||
$where_values = array();
|
||||
|
||||
if ( ! empty( $filters['clinic_id'] ) ) {
|
||||
$where_clauses[] = 'clinic_id = %d';
|
||||
$where_values[] = $filters['clinic_id'];
|
||||
}
|
||||
|
||||
if ( ! empty( $filters['date_from'] ) ) {
|
||||
$where_clauses[] = 'DATE(created_at) >= %s';
|
||||
$where_values[] = $filters['date_from'];
|
||||
}
|
||||
|
||||
if ( ! empty( $filters['date_to'] ) ) {
|
||||
$where_clauses[] = 'DATE(created_at) <= %s';
|
||||
$where_values[] = $filters['date_to'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$stats = array(
|
||||
'total_bills' => 0,
|
||||
'total_revenue' => 0,
|
||||
'pending_amount' => 0,
|
||||
'paid_amount' => 0,
|
||||
'overdue_amount' => 0,
|
||||
'bills_today' => 0,
|
||||
'bills_this_month' => 0,
|
||||
'average_bill_amount' => 0,
|
||||
'payment_status_breakdown' => array()
|
||||
);
|
||||
|
||||
// Total bills
|
||||
$query = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['total_bills'] = (int) $wpdb->get_var( $query );
|
||||
|
||||
// Total revenue (actual amount)
|
||||
$query = "SELECT SUM(CAST(actual_amount AS DECIMAL(10,2))) FROM {$table} WHERE {$where_sql}";
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['total_revenue'] = (float) $wpdb->get_var( $query ) ?: 0;
|
||||
|
||||
// Revenue by payment status
|
||||
foreach ( array_keys( self::$valid_payment_statuses ) as $status ) {
|
||||
$status_where = $where_clauses;
|
||||
$status_where[] = 'payment_status = %s';
|
||||
$status_values = array_merge( $where_values, array( $status ) );
|
||||
|
||||
$amount_query = $wpdb->prepare(
|
||||
"SELECT SUM(CAST(actual_amount AS DECIMAL(10,2))) FROM {$table} WHERE " . implode( ' AND ', $status_where ),
|
||||
$status_values
|
||||
);
|
||||
|
||||
$count_query = $wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $status_where ),
|
||||
$status_values
|
||||
);
|
||||
|
||||
$amount = (float) $wpdb->get_var( $amount_query ) ?: 0;
|
||||
$count = (int) $wpdb->get_var( $count_query );
|
||||
|
||||
$stats[ $status . '_amount' ] = $amount;
|
||||
$stats['payment_status_breakdown'][ $status ] = array(
|
||||
'count' => $count,
|
||||
'amount' => $amount
|
||||
);
|
||||
}
|
||||
|
||||
// Bills today
|
||||
$today_where = array_merge( $where_clauses, array( 'DATE(created_at) = CURDATE()' ) );
|
||||
$query = "SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $today_where );
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['bills_today'] = (int) $wpdb->get_var( $query );
|
||||
|
||||
// Bills this month
|
||||
$month_where = array_merge( $where_clauses, array(
|
||||
'MONTH(created_at) = MONTH(CURDATE())',
|
||||
'YEAR(created_at) = YEAR(CURDATE())'
|
||||
) );
|
||||
$query = "SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $month_where );
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['bills_this_month'] = (int) $wpdb->get_var( $query );
|
||||
|
||||
// Average bill amount
|
||||
if ( $stats['total_bills'] > 0 ) {
|
||||
$stats['average_bill_amount'] = round( $stats['total_revenue'] / $stats['total_bills'], 2 );
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
657
src/includes/models/class-clinic.php
Normal file
657
src/includes/models/class-clinic.php
Normal file
@@ -0,0 +1,657 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Clinic Model
|
||||
*
|
||||
* Handles clinic entity operations and business logic
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Models
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Models;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Clinic
|
||||
*
|
||||
* Model for handling clinic data and operations
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Clinic {
|
||||
|
||||
/**
|
||||
* Database table name
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private static $table_name = 'kc_clinics';
|
||||
|
||||
/**
|
||||
* Clinic ID
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $id;
|
||||
|
||||
/**
|
||||
* Clinic properties
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $data = array();
|
||||
|
||||
/**
|
||||
* Required fields for clinic creation
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $required_fields = array(
|
||||
'name',
|
||||
'email',
|
||||
'telephone_no',
|
||||
'address',
|
||||
'city',
|
||||
'state',
|
||||
'country',
|
||||
'postal_code'
|
||||
);
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param int|array $clinic_id_or_data Clinic ID or data array
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct( $clinic_id_or_data = null ) {
|
||||
if ( is_numeric( $clinic_id_or_data ) ) {
|
||||
$this->id = (int) $clinic_id_or_data;
|
||||
$this->load_data();
|
||||
} elseif ( is_array( $clinic_id_or_data ) ) {
|
||||
$this->data = $clinic_id_or_data;
|
||||
$this->id = isset( $this->data['id'] ) ? (int) $this->data['id'] : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load clinic data from database
|
||||
*
|
||||
* @return bool True on success, false on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function load_data() {
|
||||
if ( ! $this->id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$clinic_data = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE id = %d",
|
||||
$this->id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( $clinic_data ) {
|
||||
$this->data = $clinic_data;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new clinic
|
||||
*
|
||||
* @param array $clinic_data Clinic data
|
||||
* @return int|WP_Error Clinic ID on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function create( $clinic_data ) {
|
||||
// Validate required fields
|
||||
$validation = self::validate_clinic_data( $clinic_data );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
// Prepare data for insertion
|
||||
$insert_data = array(
|
||||
'name' => sanitize_text_field( $clinic_data['name'] ),
|
||||
'email' => sanitize_email( $clinic_data['email'] ),
|
||||
'telephone_no' => sanitize_text_field( $clinic_data['telephone_no'] ),
|
||||
'specialties' => isset( $clinic_data['specialties'] ) ? wp_json_encode( $clinic_data['specialties'] ) : '',
|
||||
'address' => sanitize_textarea_field( $clinic_data['address'] ),
|
||||
'city' => sanitize_text_field( $clinic_data['city'] ),
|
||||
'state' => sanitize_text_field( $clinic_data['state'] ),
|
||||
'country' => sanitize_text_field( $clinic_data['country'] ),
|
||||
'postal_code' => sanitize_text_field( $clinic_data['postal_code'] ),
|
||||
'status' => isset( $clinic_data['status'] ) ? (int) $clinic_data['status'] : 1,
|
||||
'clinic_admin_id' => isset( $clinic_data['clinic_admin_id'] ) ? (int) $clinic_data['clinic_admin_id'] : null,
|
||||
'clinic_logo' => isset( $clinic_data['clinic_logo'] ) ? (int) $clinic_data['clinic_logo'] : null,
|
||||
'profile_image' => isset( $clinic_data['profile_image'] ) ? (int) $clinic_data['profile_image'] : null,
|
||||
'extra' => isset( $clinic_data['extra'] ) ? wp_json_encode( $clinic_data['extra'] ) : '',
|
||||
'created_at' => current_time( 'mysql' )
|
||||
);
|
||||
|
||||
$insert_data = array_map( array( self::class, 'prepare_for_db' ), $insert_data );
|
||||
|
||||
$result = $wpdb->insert( $table, $insert_data );
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'clinic_creation_failed',
|
||||
'Failed to create clinic: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return $wpdb->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update clinic data
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @param array $clinic_data Updated clinic data
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function update( $clinic_id, $clinic_data ) {
|
||||
if ( ! self::exists( $clinic_id ) ) {
|
||||
return new \WP_Error(
|
||||
'clinic_not_found',
|
||||
'Clinic not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
// Prepare update data
|
||||
$update_data = array();
|
||||
$allowed_fields = array(
|
||||
'name', 'email', 'telephone_no', 'specialties', 'address',
|
||||
'city', 'state', 'country', 'postal_code', 'status',
|
||||
'clinic_admin_id', 'clinic_logo', 'profile_image', 'extra'
|
||||
);
|
||||
|
||||
foreach ( $allowed_fields as $field ) {
|
||||
if ( isset( $clinic_data[ $field ] ) ) {
|
||||
$value = $clinic_data[ $field ];
|
||||
|
||||
switch ( $field ) {
|
||||
case 'email':
|
||||
$update_data[ $field ] = sanitize_email( $value );
|
||||
break;
|
||||
case 'specialties':
|
||||
case 'extra':
|
||||
$update_data[ $field ] = is_array( $value ) ? wp_json_encode( $value ) : $value;
|
||||
break;
|
||||
case 'status':
|
||||
case 'clinic_admin_id':
|
||||
case 'clinic_logo':
|
||||
case 'profile_image':
|
||||
$update_data[ $field ] = (int) $value;
|
||||
break;
|
||||
case 'address':
|
||||
$update_data[ $field ] = sanitize_textarea_field( $value );
|
||||
break;
|
||||
default:
|
||||
$update_data[ $field ] = sanitize_text_field( $value );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $update_data ) ) {
|
||||
return new \WP_Error(
|
||||
'no_data_to_update',
|
||||
'No valid data provided for update',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$update_data = array_map( array( self::class, 'prepare_for_db' ), $update_data );
|
||||
|
||||
$result = $wpdb->update(
|
||||
$table,
|
||||
$update_data,
|
||||
array( 'id' => $clinic_id ),
|
||||
null,
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'clinic_update_failed',
|
||||
'Failed to update clinic: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a clinic
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function delete( $clinic_id ) {
|
||||
if ( ! self::exists( $clinic_id ) ) {
|
||||
return new \WP_Error(
|
||||
'clinic_not_found',
|
||||
'Clinic not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check for dependencies
|
||||
if ( self::has_dependencies( $clinic_id ) ) {
|
||||
return new \WP_Error(
|
||||
'clinic_has_dependencies',
|
||||
'Cannot delete clinic with associated appointments or patients',
|
||||
array( 'status' => 409 )
|
||||
);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$result = $wpdb->delete(
|
||||
$table,
|
||||
array( 'id' => $clinic_id ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'clinic_deletion_failed',
|
||||
'Failed to delete clinic: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clinic by ID
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return array|null Clinic data or null if not found
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_by_id( $clinic_id ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$clinic = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE id = %d",
|
||||
$clinic_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( $clinic ) {
|
||||
return self::format_clinic_data( $clinic );
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all clinics with optional filtering
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return array Array of clinic data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_all( $args = array() ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$defaults = array(
|
||||
'status' => null,
|
||||
'search' => '',
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
'orderby' => 'name',
|
||||
'order' => 'ASC'
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$where_clauses = array( '1=1' );
|
||||
$where_values = array();
|
||||
|
||||
// Status filter
|
||||
if ( ! is_null( $args['status'] ) ) {
|
||||
$where_clauses[] = 'status = %d';
|
||||
$where_values[] = $args['status'];
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if ( ! empty( $args['search'] ) ) {
|
||||
$where_clauses[] = '(name LIKE %s OR email LIKE %s OR city LIKE %s)';
|
||||
$search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%';
|
||||
$where_values[] = $search_term;
|
||||
$where_values[] = $search_term;
|
||||
$where_values[] = $search_term;
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
// Build query
|
||||
$query = "SELECT * FROM {$table} WHERE {$where_sql}";
|
||||
$query .= sprintf( ' ORDER BY %s %s',
|
||||
sanitize_sql_orderby( $args['orderby'] ),
|
||||
sanitize_sql_orderby( $args['order'] )
|
||||
);
|
||||
$query .= $wpdb->prepare( ' LIMIT %d OFFSET %d', $args['limit'], $args['offset'] );
|
||||
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
|
||||
$clinics = $wpdb->get_results( $query, ARRAY_A );
|
||||
|
||||
return array_map( array( self::class, 'format_clinic_data' ), $clinics );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of clinics
|
||||
*
|
||||
* @param array $args Filter arguments
|
||||
* @return int Total count
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_count( $args = array() ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$where_clauses = array( '1=1' );
|
||||
$where_values = array();
|
||||
|
||||
if ( ! is_null( $args['status'] ?? null ) ) {
|
||||
$where_clauses[] = 'status = %d';
|
||||
$where_values[] = $args['status'];
|
||||
}
|
||||
|
||||
if ( ! empty( $args['search'] ?? '' ) ) {
|
||||
$where_clauses[] = '(name LIKE %s OR email LIKE %s OR city LIKE %s)';
|
||||
$search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%';
|
||||
$where_values[] = $search_term;
|
||||
$where_values[] = $search_term;
|
||||
$where_values[] = $search_term;
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
$query = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
|
||||
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
|
||||
return (int) $wpdb->get_var( $query );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if clinic exists
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return bool True if exists, false otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function exists( $clinic_id ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE id = %d",
|
||||
$clinic_id
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if clinic has dependencies (appointments, patients, etc.)
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return bool True if has dependencies, false otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function has_dependencies( $clinic_id ) {
|
||||
global $wpdb;
|
||||
|
||||
// Check appointments
|
||||
$appointments_count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments WHERE clinic_id = %d",
|
||||
$clinic_id
|
||||
)
|
||||
);
|
||||
|
||||
if ( (int) $appointments_count > 0 ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check patient mappings
|
||||
$patients_count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_patient_clinic_mappings WHERE clinic_id = %d",
|
||||
$clinic_id
|
||||
)
|
||||
);
|
||||
|
||||
if ( (int) $patients_count > 0 ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check doctor mappings
|
||||
$doctors_count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_doctor_clinic_mappings WHERE clinic_id = %d",
|
||||
$clinic_id
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $doctors_count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate clinic data
|
||||
*
|
||||
* @param array $clinic_data Clinic data to validate
|
||||
* @return bool|WP_Error True if valid, WP_Error if invalid
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_clinic_data( $clinic_data ) {
|
||||
$errors = array();
|
||||
|
||||
// Check required fields
|
||||
foreach ( self::$required_fields as $field ) {
|
||||
if ( empty( $clinic_data[ $field ] ) ) {
|
||||
$errors[] = "Field '{$field}' is required";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if ( ! empty( $clinic_data['email'] ) && ! is_email( $clinic_data['email'] ) ) {
|
||||
$errors[] = 'Invalid email format';
|
||||
}
|
||||
|
||||
// Check for duplicate email
|
||||
if ( ! empty( $clinic_data['email'] ) ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$existing_clinic = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE email = %s",
|
||||
$clinic_data['email']
|
||||
)
|
||||
);
|
||||
|
||||
if ( $existing_clinic ) {
|
||||
$errors[] = 'A clinic with this email already exists';
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $errors ) ) {
|
||||
return new \WP_Error(
|
||||
'clinic_validation_failed',
|
||||
'Clinic validation failed',
|
||||
array(
|
||||
'status' => 400,
|
||||
'errors' => $errors
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format clinic data for API response
|
||||
*
|
||||
* @param array $clinic_data Raw clinic data
|
||||
* @return array Formatted clinic data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function format_clinic_data( $clinic_data ) {
|
||||
if ( ! $clinic_data ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse JSON fields
|
||||
if ( ! empty( $clinic_data['specialties'] ) ) {
|
||||
$clinic_data['specialties'] = json_decode( $clinic_data['specialties'], true ) ?: array();
|
||||
}
|
||||
|
||||
if ( ! empty( $clinic_data['extra'] ) ) {
|
||||
$clinic_data['extra'] = json_decode( $clinic_data['extra'], true ) ?: array();
|
||||
}
|
||||
|
||||
// Cast numeric fields
|
||||
$numeric_fields = array( 'id', 'status', 'clinic_admin_id', 'clinic_logo', 'profile_image' );
|
||||
foreach ( $numeric_fields as $field ) {
|
||||
if ( isset( $clinic_data[ $field ] ) ) {
|
||||
$clinic_data[ $field ] = (int) $clinic_data[ $field ];
|
||||
}
|
||||
}
|
||||
|
||||
return $clinic_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare data for database insertion/update
|
||||
*
|
||||
* @param mixed $value Data value
|
||||
* @return mixed Prepared value
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function prepare_for_db( $value ) {
|
||||
if ( is_null( $value ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( is_array( $value ) ) {
|
||||
return wp_json_encode( $value );
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clinic statistics
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return array Clinic statistics
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_statistics( $clinic_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$stats = array(
|
||||
'total_appointments' => 0,
|
||||
'total_patients' => 0,
|
||||
'total_doctors' => 0,
|
||||
'revenue_this_month' => 0,
|
||||
'appointments_today' => 0
|
||||
);
|
||||
|
||||
// Total appointments
|
||||
$stats['total_appointments'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments WHERE clinic_id = %d",
|
||||
$clinic_id
|
||||
)
|
||||
);
|
||||
|
||||
// Total patients
|
||||
$stats['total_patients'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_patient_clinic_mappings WHERE clinic_id = %d",
|
||||
$clinic_id
|
||||
)
|
||||
);
|
||||
|
||||
// Total doctors
|
||||
$stats['total_doctors'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_doctor_clinic_mappings WHERE clinic_id = %d",
|
||||
$clinic_id
|
||||
)
|
||||
);
|
||||
|
||||
// Revenue this month
|
||||
$stats['revenue_this_month'] = (float) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT SUM(CAST(actual_amount AS DECIMAL(10,2)))
|
||||
FROM {$wpdb->prefix}kc_bills
|
||||
WHERE clinic_id = %d
|
||||
AND MONTH(created_at) = MONTH(CURRENT_DATE())
|
||||
AND YEAR(created_at) = YEAR(CURRENT_DATE())
|
||||
AND payment_status = 'paid'",
|
||||
$clinic_id
|
||||
)
|
||||
) ?: 0;
|
||||
|
||||
// Appointments today
|
||||
$stats['appointments_today'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments
|
||||
WHERE clinic_id = %d
|
||||
AND appointment_start_date = CURDATE()",
|
||||
$clinic_id
|
||||
)
|
||||
);
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
980
src/includes/models/class-doctor.php
Normal file
980
src/includes/models/class-doctor.php
Normal file
@@ -0,0 +1,980 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Doctor Model
|
||||
*
|
||||
* Handles doctor entity operations, schedules and clinic associations
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Models
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Models;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Doctor
|
||||
*
|
||||
* Model for handling doctor data, schedules and clinic management
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Doctor {
|
||||
|
||||
/**
|
||||
* Doctor user ID (wp_users table)
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $user_id;
|
||||
|
||||
/**
|
||||
* Doctor data
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $data = array();
|
||||
|
||||
/**
|
||||
* Required fields for doctor registration
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $required_fields = array(
|
||||
'first_name',
|
||||
'last_name',
|
||||
'user_email',
|
||||
'specialization',
|
||||
'qualification'
|
||||
);
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param int|array $user_id_or_data Doctor user ID or data array
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct( $user_id_or_data = null ) {
|
||||
if ( is_numeric( $user_id_or_data ) ) {
|
||||
$this->user_id = (int) $user_id_or_data;
|
||||
$this->load_data();
|
||||
} elseif ( is_array( $user_id_or_data ) ) {
|
||||
$this->data = $user_id_or_data;
|
||||
$this->user_id = isset( $this->data['user_id'] ) ? (int) $this->data['user_id'] : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load doctor data from database
|
||||
*
|
||||
* @return bool True on success, false on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function load_data() {
|
||||
if ( ! $this->user_id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$doctor_data = self::get_doctor_full_data( $this->user_id );
|
||||
|
||||
if ( $doctor_data ) {
|
||||
$this->data = $doctor_data;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new doctor
|
||||
*
|
||||
* @param array $doctor_data Doctor data
|
||||
* @return int|WP_Error Doctor user ID on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function create( $doctor_data ) {
|
||||
// Validate required fields
|
||||
$validation = self::validate_doctor_data( $doctor_data );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
// Create WordPress user
|
||||
$user_data = array(
|
||||
'user_login' => self::generate_unique_username( $doctor_data ),
|
||||
'user_email' => sanitize_email( $doctor_data['user_email'] ),
|
||||
'first_name' => sanitize_text_field( $doctor_data['first_name'] ),
|
||||
'last_name' => sanitize_text_field( $doctor_data['last_name'] ),
|
||||
'role' => 'kivicare_doctor',
|
||||
'user_pass' => isset( $doctor_data['user_pass'] ) ? $doctor_data['user_pass'] : wp_generate_password()
|
||||
);
|
||||
|
||||
$user_id = wp_insert_user( $user_data );
|
||||
|
||||
if ( is_wp_error( $user_id ) ) {
|
||||
return new \WP_Error(
|
||||
'doctor_creation_failed',
|
||||
'Failed to create doctor user: ' . $user_id->get_error_message(),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
// Add doctor meta data
|
||||
$meta_fields = array(
|
||||
'specialization' => sanitize_text_field( $doctor_data['specialization'] ),
|
||||
'qualification' => sanitize_text_field( $doctor_data['qualification'] ),
|
||||
'experience_years' => isset( $doctor_data['experience_years'] ) ? (int) $doctor_data['experience_years'] : 0,
|
||||
'mobile_number' => isset( $doctor_data['mobile_number'] ) ? sanitize_text_field( $doctor_data['mobile_number'] ) : '',
|
||||
'address' => isset( $doctor_data['address'] ) ? sanitize_textarea_field( $doctor_data['address'] ) : '',
|
||||
'city' => isset( $doctor_data['city'] ) ? sanitize_text_field( $doctor_data['city'] ) : '',
|
||||
'state' => isset( $doctor_data['state'] ) ? sanitize_text_field( $doctor_data['state'] ) : '',
|
||||
'country' => isset( $doctor_data['country'] ) ? sanitize_text_field( $doctor_data['country'] ) : '',
|
||||
'postal_code' => isset( $doctor_data['postal_code'] ) ? sanitize_text_field( $doctor_data['postal_code'] ) : '',
|
||||
'license_number' => isset( $doctor_data['license_number'] ) ? sanitize_text_field( $doctor_data['license_number'] ) : '',
|
||||
'consultation_fee' => isset( $doctor_data['consultation_fee'] ) ? (float) $doctor_data['consultation_fee'] : 0,
|
||||
'biography' => isset( $doctor_data['biography'] ) ? sanitize_textarea_field( $doctor_data['biography'] ) : '',
|
||||
'languages' => isset( $doctor_data['languages'] ) ? $doctor_data['languages'] : array(),
|
||||
'working_hours' => isset( $doctor_data['working_hours'] ) ? $doctor_data['working_hours'] : array(),
|
||||
'doctor_registration_date' => current_time( 'mysql' ),
|
||||
'doctor_status' => 'active'
|
||||
);
|
||||
|
||||
foreach ( $meta_fields as $meta_key => $meta_value ) {
|
||||
if ( is_array( $meta_value ) ) {
|
||||
$meta_value = wp_json_encode( $meta_value );
|
||||
}
|
||||
update_user_meta( $user_id, $meta_key, $meta_value );
|
||||
}
|
||||
|
||||
// Create clinic mapping if clinic_id provided
|
||||
if ( ! empty( $doctor_data['clinic_id'] ) ) {
|
||||
self::assign_to_clinic( $user_id, $doctor_data['clinic_id'] );
|
||||
}
|
||||
|
||||
return $user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update doctor data
|
||||
*
|
||||
* @param int $user_id Doctor user ID
|
||||
* @param array $doctor_data Updated doctor data
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function update( $user_id, $doctor_data ) {
|
||||
if ( ! self::exists( $user_id ) ) {
|
||||
return new \WP_Error(
|
||||
'doctor_not_found',
|
||||
'Doctor not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Update user data
|
||||
$user_update_data = array( 'ID' => $user_id );
|
||||
$user_fields = array( 'first_name', 'last_name', 'user_email' );
|
||||
|
||||
foreach ( $user_fields as $field ) {
|
||||
if ( isset( $doctor_data[ $field ] ) ) {
|
||||
$user_update_data[ $field ] = sanitize_text_field( $doctor_data[ $field ] );
|
||||
}
|
||||
}
|
||||
|
||||
if ( count( $user_update_data ) > 1 ) {
|
||||
$result = wp_update_user( $user_update_data );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return new \WP_Error(
|
||||
'doctor_update_failed',
|
||||
'Failed to update doctor: ' . $result->get_error_message(),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update meta fields
|
||||
$meta_fields = array(
|
||||
'specialization', 'qualification', 'experience_years', 'mobile_number',
|
||||
'address', 'city', 'state', 'country', 'postal_code', 'license_number',
|
||||
'consultation_fee', 'biography', 'languages', 'working_hours', 'doctor_status'
|
||||
);
|
||||
|
||||
foreach ( $meta_fields as $meta_key ) {
|
||||
if ( isset( $doctor_data[ $meta_key ] ) ) {
|
||||
$value = $doctor_data[ $meta_key ];
|
||||
|
||||
if ( in_array( $meta_key, array( 'address', 'biography' ) ) ) {
|
||||
$value = sanitize_textarea_field( $value );
|
||||
} elseif ( $meta_key === 'experience_years' ) {
|
||||
$value = (int) $value;
|
||||
} elseif ( $meta_key === 'consultation_fee' ) {
|
||||
$value = (float) $value;
|
||||
} elseif ( in_array( $meta_key, array( 'languages', 'working_hours' ) ) ) {
|
||||
$value = is_array( $value ) ? wp_json_encode( $value ) : $value;
|
||||
} else {
|
||||
$value = sanitize_text_field( $value );
|
||||
}
|
||||
|
||||
update_user_meta( $user_id, $meta_key, $value );
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a doctor (soft delete - deactivate user)
|
||||
*
|
||||
* @param int $user_id Doctor user ID
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function delete( $user_id ) {
|
||||
if ( ! self::exists( $user_id ) ) {
|
||||
return new \WP_Error(
|
||||
'doctor_not_found',
|
||||
'Doctor not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check for dependencies
|
||||
if ( self::has_dependencies( $user_id ) ) {
|
||||
return new \WP_Error(
|
||||
'doctor_has_dependencies',
|
||||
'Cannot delete doctor with existing appointments or patient encounters',
|
||||
array( 'status' => 409 )
|
||||
);
|
||||
}
|
||||
|
||||
// Soft delete - set user status to inactive
|
||||
update_user_meta( $user_id, 'doctor_status', 'inactive' );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doctor by ID
|
||||
*
|
||||
* @param int $user_id Doctor user ID
|
||||
* @return array|null Doctor data or null if not found
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_by_id( $user_id ) {
|
||||
if ( ! self::exists( $user_id ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::get_doctor_full_data( $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all doctors with optional filtering
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return array Array of doctor data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_all( $args = array() ) {
|
||||
$defaults = array(
|
||||
'clinic_id' => null,
|
||||
'specialization' => null,
|
||||
'status' => 'active',
|
||||
'search' => '',
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
'orderby' => 'display_name',
|
||||
'order' => 'ASC'
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$user_query_args = array(
|
||||
'role' => 'kivicare_doctor',
|
||||
'number' => $args['limit'],
|
||||
'offset' => $args['offset'],
|
||||
'orderby' => $args['orderby'],
|
||||
'order' => $args['order'],
|
||||
'fields' => 'ID',
|
||||
'meta_query' => array()
|
||||
);
|
||||
|
||||
// Add search
|
||||
if ( ! empty( $args['search'] ) ) {
|
||||
$user_query_args['search'] = '*' . $args['search'] . '*';
|
||||
$user_query_args['search_columns'] = array( 'user_login', 'user_email', 'display_name' );
|
||||
}
|
||||
|
||||
// Add status filter
|
||||
if ( ! empty( $args['status'] ) ) {
|
||||
$user_query_args['meta_query'][] = array(
|
||||
'relation' => 'OR',
|
||||
array(
|
||||
'key' => 'doctor_status',
|
||||
'value' => $args['status'],
|
||||
'compare' => '='
|
||||
),
|
||||
array(
|
||||
'key' => 'doctor_status',
|
||||
'compare' => 'NOT EXISTS'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Add specialization filter
|
||||
if ( ! empty( $args['specialization'] ) ) {
|
||||
$user_query_args['meta_query'][] = array(
|
||||
'key' => 'specialization',
|
||||
'value' => $args['specialization'],
|
||||
'compare' => 'LIKE'
|
||||
);
|
||||
}
|
||||
|
||||
$user_query = new \WP_User_Query( $user_query_args );
|
||||
$user_ids = $user_query->get_results();
|
||||
|
||||
$doctors = array();
|
||||
foreach ( $user_ids as $user_id ) {
|
||||
$doctor_data = self::get_doctor_full_data( $user_id );
|
||||
|
||||
// Filter by clinic if specified
|
||||
if ( ! is_null( $args['clinic_id'] ) &&
|
||||
! in_array( (int) $args['clinic_id'], $doctor_data['clinic_ids'] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $doctor_data ) {
|
||||
$doctors[] = $doctor_data;
|
||||
}
|
||||
}
|
||||
|
||||
return $doctors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doctor full data with clinic information
|
||||
*
|
||||
* @param int $user_id Doctor user ID
|
||||
* @return array|null Full doctor data or null if not found
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_doctor_full_data( $user_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$user = get_user_by( 'id', $user_id );
|
||||
if ( ! $user || ! in_array( 'kivicare_doctor', $user->roles ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get clinic mappings
|
||||
$clinic_mappings = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT dcm.clinic_id, c.name as clinic_name
|
||||
FROM {$wpdb->prefix}kc_doctor_clinic_mappings dcm
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON dcm.clinic_id = c.id
|
||||
WHERE dcm.doctor_id = %d",
|
||||
$user_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$clinic_ids = array();
|
||||
$clinic_names = array();
|
||||
foreach ( $clinic_mappings as $mapping ) {
|
||||
$clinic_ids[] = (int) $mapping['clinic_id'];
|
||||
$clinic_names[] = $mapping['clinic_name'];
|
||||
}
|
||||
|
||||
// Get doctor meta data
|
||||
$languages = get_user_meta( $user_id, 'languages', true );
|
||||
$working_hours = get_user_meta( $user_id, 'working_hours', true );
|
||||
|
||||
if ( is_string( $languages ) ) {
|
||||
$languages = json_decode( $languages, true ) ?: array();
|
||||
}
|
||||
|
||||
if ( is_string( $working_hours ) ) {
|
||||
$working_hours = json_decode( $working_hours, true ) ?: array();
|
||||
}
|
||||
|
||||
$doctor_data = array(
|
||||
'user_id' => $user_id,
|
||||
'username' => $user->user_login,
|
||||
'email' => $user->user_email,
|
||||
'first_name' => $user->first_name,
|
||||
'last_name' => $user->last_name,
|
||||
'display_name' => $user->display_name,
|
||||
'specialization' => get_user_meta( $user_id, 'specialization', true ),
|
||||
'qualification' => get_user_meta( $user_id, 'qualification', true ),
|
||||
'experience_years' => (int) get_user_meta( $user_id, 'experience_years', true ),
|
||||
'mobile_number' => get_user_meta( $user_id, 'mobile_number', true ),
|
||||
'address' => get_user_meta( $user_id, 'address', true ),
|
||||
'city' => get_user_meta( $user_id, 'city', true ),
|
||||
'state' => get_user_meta( $user_id, 'state', true ),
|
||||
'country' => get_user_meta( $user_id, 'country', true ),
|
||||
'postal_code' => get_user_meta( $user_id, 'postal_code', true ),
|
||||
'license_number' => get_user_meta( $user_id, 'license_number', true ),
|
||||
'consultation_fee' => (float) get_user_meta( $user_id, 'consultation_fee', true ),
|
||||
'biography' => get_user_meta( $user_id, 'biography', true ),
|
||||
'languages' => $languages,
|
||||
'working_hours' => $working_hours,
|
||||
'status' => get_user_meta( $user_id, 'doctor_status', true ) ?: 'active',
|
||||
'registration_date' => get_user_meta( $user_id, 'doctor_registration_date', true ),
|
||||
'clinic_ids' => $clinic_ids,
|
||||
'clinic_names' => $clinic_names,
|
||||
'total_appointments' => self::get_appointment_count( $user_id ),
|
||||
'total_patients' => self::get_patient_count( $user_id ),
|
||||
'rating' => self::get_doctor_rating( $user_id )
|
||||
);
|
||||
|
||||
return $doctor_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doctor schedule
|
||||
*
|
||||
* @param int $user_id Doctor user ID
|
||||
* @param array $args Query arguments
|
||||
* @return array Doctor schedule
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_schedule( $user_id, $args = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
$defaults = array(
|
||||
'date_from' => date( 'Y-m-d' ),
|
||||
'date_to' => date( 'Y-m-d', strtotime( '+30 days' ) ),
|
||||
'clinic_id' => null
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
// Get appointments in date range
|
||||
$where_clauses = array(
|
||||
'doctor_id = %d',
|
||||
'appointment_start_date BETWEEN %s AND %s'
|
||||
);
|
||||
$where_values = array( $user_id, $args['date_from'], $args['date_to'] );
|
||||
|
||||
if ( ! is_null( $args['clinic_id'] ) ) {
|
||||
$where_clauses[] = 'clinic_id = %d';
|
||||
$where_values[] = $args['clinic_id'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$appointments = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT a.*,
|
||||
CONCAT(p.first_name, ' ', p.last_name) as patient_name,
|
||||
c.name as clinic_name
|
||||
FROM {$wpdb->prefix}kc_appointments a
|
||||
LEFT JOIN {$wpdb->prefix}users p ON a.patient_id = p.ID
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON a.clinic_id = c.id
|
||||
WHERE {$where_sql}
|
||||
ORDER BY appointment_start_date, appointment_start_time",
|
||||
$where_values
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
// Get working hours
|
||||
$working_hours = get_user_meta( $user_id, 'working_hours', true );
|
||||
if ( is_string( $working_hours ) ) {
|
||||
$working_hours = json_decode( $working_hours, true ) ?: array();
|
||||
}
|
||||
|
||||
$schedule = array(
|
||||
'working_hours' => $working_hours,
|
||||
'appointments' => array()
|
||||
);
|
||||
|
||||
foreach ( $appointments as $appointment ) {
|
||||
$schedule['appointments'][] = array(
|
||||
'id' => (int) $appointment['id'],
|
||||
'date' => $appointment['appointment_start_date'],
|
||||
'start_time' => $appointment['appointment_start_time'],
|
||||
'end_time' => $appointment['appointment_end_time'],
|
||||
'patient' => array(
|
||||
'id' => (int) $appointment['patient_id'],
|
||||
'name' => $appointment['patient_name']
|
||||
),
|
||||
'clinic' => array(
|
||||
'id' => (int) $appointment['clinic_id'],
|
||||
'name' => $appointment['clinic_name']
|
||||
),
|
||||
'visit_type' => $appointment['visit_type'],
|
||||
'status' => (int) $appointment['status'],
|
||||
'description' => $appointment['description']
|
||||
);
|
||||
}
|
||||
|
||||
return $schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update doctor schedule/working hours
|
||||
*
|
||||
* @param int $user_id Doctor user ID
|
||||
* @param array $working_hours Working hours data
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function update_schedule( $user_id, $working_hours ) {
|
||||
if ( ! self::exists( $user_id ) ) {
|
||||
return new \WP_Error(
|
||||
'doctor_not_found',
|
||||
'Doctor not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Validate working hours format
|
||||
$validation = self::validate_working_hours( $working_hours );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
update_user_meta( $user_id, 'working_hours', wp_json_encode( $working_hours ) );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doctor appointments
|
||||
*
|
||||
* @param int $user_id Doctor user ID
|
||||
* @param array $args Query arguments
|
||||
* @return array Doctor appointments
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_appointments( $user_id, $args = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
$defaults = array(
|
||||
'status' => null,
|
||||
'date_from' => null,
|
||||
'date_to' => null,
|
||||
'clinic_id' => null,
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
'orderby' => 'appointment_start_date',
|
||||
'order' => 'ASC'
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$where_clauses = array( 'doctor_id = %d' );
|
||||
$where_values = array( $user_id );
|
||||
|
||||
if ( ! is_null( $args['status'] ) ) {
|
||||
$where_clauses[] = 'status = %d';
|
||||
$where_values[] = $args['status'];
|
||||
}
|
||||
|
||||
if ( ! is_null( $args['date_from'] ) ) {
|
||||
$where_clauses[] = 'appointment_start_date >= %s';
|
||||
$where_values[] = $args['date_from'];
|
||||
}
|
||||
|
||||
if ( ! is_null( $args['date_to'] ) ) {
|
||||
$where_clauses[] = 'appointment_start_date <= %s';
|
||||
$where_values[] = $args['date_to'];
|
||||
}
|
||||
|
||||
if ( ! is_null( $args['clinic_id'] ) ) {
|
||||
$where_clauses[] = 'clinic_id = %d';
|
||||
$where_values[] = $args['clinic_id'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$query = $wpdb->prepare(
|
||||
"SELECT a.*,
|
||||
CONCAT(p.first_name, ' ', p.last_name) as patient_name,
|
||||
c.name as clinic_name
|
||||
FROM {$wpdb->prefix}kc_appointments a
|
||||
LEFT JOIN {$wpdb->prefix}users p ON a.patient_id = p.ID
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON a.clinic_id = c.id
|
||||
WHERE {$where_sql}
|
||||
ORDER BY {$args['orderby']} {$args['order']}
|
||||
LIMIT %d OFFSET %d",
|
||||
array_merge( $where_values, array( $args['limit'], $args['offset'] ) )
|
||||
);
|
||||
|
||||
$appointments = $wpdb->get_results( $query, ARRAY_A );
|
||||
|
||||
return array_map( function( $appointment ) {
|
||||
return array(
|
||||
'id' => (int) $appointment['id'],
|
||||
'start_date' => $appointment['appointment_start_date'],
|
||||
'start_time' => $appointment['appointment_start_time'],
|
||||
'end_date' => $appointment['appointment_end_date'],
|
||||
'end_time' => $appointment['appointment_end_time'],
|
||||
'visit_type' => $appointment['visit_type'],
|
||||
'status' => (int) $appointment['status'],
|
||||
'patient' => array(
|
||||
'id' => (int) $appointment['patient_id'],
|
||||
'name' => $appointment['patient_name']
|
||||
),
|
||||
'clinic' => array(
|
||||
'id' => (int) $appointment['clinic_id'],
|
||||
'name' => $appointment['clinic_name']
|
||||
),
|
||||
'description' => $appointment['description'],
|
||||
'appointment_report' => $appointment['appointment_report'],
|
||||
'created_at' => $appointment['created_at']
|
||||
);
|
||||
}, $appointments );
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign doctor to clinic
|
||||
*
|
||||
* @param int $user_id Doctor user ID
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function assign_to_clinic( $user_id, $clinic_id ) {
|
||||
global $wpdb;
|
||||
|
||||
// Check if clinic exists
|
||||
if ( ! Clinic::exists( $clinic_id ) ) {
|
||||
return new \WP_Error(
|
||||
'clinic_not_found',
|
||||
'Clinic not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check if mapping already exists
|
||||
$existing_mapping = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_doctor_clinic_mappings
|
||||
WHERE doctor_id = %d AND clinic_id = %d",
|
||||
$user_id, $clinic_id
|
||||
)
|
||||
);
|
||||
|
||||
if ( (int) $existing_mapping > 0 ) {
|
||||
return true; // Already assigned
|
||||
}
|
||||
|
||||
// Create new mapping
|
||||
$result = $wpdb->insert(
|
||||
"{$wpdb->prefix}kc_doctor_clinic_mappings",
|
||||
array(
|
||||
'doctor_id' => $user_id,
|
||||
'clinic_id' => $clinic_id,
|
||||
'created_at' => current_time( 'mysql' )
|
||||
),
|
||||
array( '%d', '%d', '%s' )
|
||||
);
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'clinic_assignment_failed',
|
||||
'Failed to assign doctor to clinic: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if doctor exists
|
||||
*
|
||||
* @param int $user_id Doctor user ID
|
||||
* @return bool True if exists, false otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function exists( $user_id ) {
|
||||
$user = get_user_by( 'id', $user_id );
|
||||
return $user && in_array( 'kivicare_doctor', $user->roles );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if doctor has dependencies (appointments, encounters, etc.)
|
||||
*
|
||||
* @param int $user_id Doctor user ID
|
||||
* @return bool True if has dependencies, false otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function has_dependencies( $user_id ) {
|
||||
global $wpdb;
|
||||
|
||||
// Check appointments
|
||||
$appointments_count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments WHERE doctor_id = %d",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
if ( (int) $appointments_count > 0 ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check encounters
|
||||
$encounters_count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_patient_encounters WHERE doctor_id = %d",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $encounters_count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate doctor data
|
||||
*
|
||||
* @param array $doctor_data Doctor data to validate
|
||||
* @return bool|WP_Error True if valid, WP_Error if invalid
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_doctor_data( $doctor_data ) {
|
||||
$errors = array();
|
||||
|
||||
// Check required fields
|
||||
foreach ( self::$required_fields as $field ) {
|
||||
if ( empty( $doctor_data[ $field ] ) ) {
|
||||
$errors[] = "Field '{$field}' is required";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if ( ! empty( $doctor_data['user_email'] ) && ! is_email( $doctor_data['user_email'] ) ) {
|
||||
$errors[] = 'Invalid email format';
|
||||
}
|
||||
|
||||
// Check for duplicate email
|
||||
if ( ! empty( $doctor_data['user_email'] ) ) {
|
||||
$existing_user = get_user_by( 'email', $doctor_data['user_email'] );
|
||||
if ( $existing_user ) {
|
||||
$errors[] = 'A user with this email already exists';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate consultation fee
|
||||
if ( isset( $doctor_data['consultation_fee'] ) &&
|
||||
! is_numeric( $doctor_data['consultation_fee'] ) ) {
|
||||
$errors[] = 'Invalid consultation fee. Must be a number';
|
||||
}
|
||||
|
||||
// Validate experience years
|
||||
if ( isset( $doctor_data['experience_years'] ) &&
|
||||
( ! is_numeric( $doctor_data['experience_years'] ) ||
|
||||
(int) $doctor_data['experience_years'] < 0 ) ) {
|
||||
$errors[] = 'Invalid experience years. Must be a positive number';
|
||||
}
|
||||
|
||||
if ( ! empty( $errors ) ) {
|
||||
return new \WP_Error(
|
||||
'doctor_validation_failed',
|
||||
'Doctor validation failed',
|
||||
array(
|
||||
'status' => 400,
|
||||
'errors' => $errors
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate working hours format
|
||||
*
|
||||
* @param array $working_hours Working hours data
|
||||
* @return bool|WP_Error True if valid, WP_Error if invalid
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_working_hours( $working_hours ) {
|
||||
if ( ! is_array( $working_hours ) ) {
|
||||
return new \WP_Error(
|
||||
'invalid_working_hours',
|
||||
'Working hours must be an array',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$valid_days = array( 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday' );
|
||||
|
||||
foreach ( $working_hours as $day => $hours ) {
|
||||
if ( ! in_array( strtolower( $day ), $valid_days ) ) {
|
||||
return new \WP_Error(
|
||||
'invalid_day',
|
||||
"Invalid day: {$day}",
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
if ( isset( $hours['start_time'] ) && isset( $hours['end_time'] ) ) {
|
||||
// Validate time format (HH:MM)
|
||||
if ( ! preg_match( '/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $hours['start_time'] ) ||
|
||||
! preg_match( '/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $hours['end_time'] ) ) {
|
||||
return new \WP_Error(
|
||||
'invalid_time_format',
|
||||
'Time must be in HH:MM format',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique username for doctor
|
||||
*
|
||||
* @param array $doctor_data Doctor data
|
||||
* @return string Unique username
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function generate_unique_username( $doctor_data ) {
|
||||
$base_username = 'dr.' . strtolower(
|
||||
$doctor_data['first_name'] . '.' . $doctor_data['last_name']
|
||||
);
|
||||
$base_username = sanitize_user( $base_username );
|
||||
|
||||
$username = $base_username;
|
||||
$counter = 1;
|
||||
|
||||
while ( username_exists( $username ) ) {
|
||||
$username = $base_username . $counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
return $username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doctor appointment count
|
||||
*
|
||||
* @param int $user_id Doctor user ID
|
||||
* @return int Total appointments
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_appointment_count( $user_id ) {
|
||||
global $wpdb;
|
||||
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments WHERE doctor_id = %d",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doctor patient count
|
||||
*
|
||||
* @param int $user_id Doctor user ID
|
||||
* @return int Unique patients treated
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_patient_count( $user_id ) {
|
||||
global $wpdb;
|
||||
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT patient_id) FROM {$wpdb->prefix}kc_appointments WHERE doctor_id = %d",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doctor rating (placeholder for future implementation)
|
||||
*
|
||||
* @param int $user_id Doctor user ID
|
||||
* @return float Doctor rating
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_doctor_rating( $user_id ) {
|
||||
// Placeholder for future rating system
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doctor statistics
|
||||
*
|
||||
* @param int $user_id Doctor user ID
|
||||
* @return array Doctor statistics
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_statistics( $user_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$stats = array(
|
||||
'total_appointments' => self::get_appointment_count( $user_id ),
|
||||
'total_patients' => self::get_patient_count( $user_id ),
|
||||
'appointments_today' => 0,
|
||||
'appointments_this_week' => 0,
|
||||
'appointments_this_month' => 0,
|
||||
'completed_encounters' => 0,
|
||||
'revenue_this_month' => 0
|
||||
);
|
||||
|
||||
// Appointments today
|
||||
$stats['appointments_today'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments
|
||||
WHERE doctor_id = %d AND appointment_start_date = CURDATE()",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
// Appointments this week
|
||||
$stats['appointments_this_week'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments
|
||||
WHERE doctor_id = %d AND WEEK(appointment_start_date) = WEEK(CURDATE())
|
||||
AND YEAR(appointment_start_date) = YEAR(CURDATE())",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
// Appointments this month
|
||||
$stats['appointments_this_month'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments
|
||||
WHERE doctor_id = %d AND MONTH(appointment_start_date) = MONTH(CURDATE())
|
||||
AND YEAR(appointment_start_date) = YEAR(CURDATE())",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
// Completed encounters
|
||||
$stats['completed_encounters'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_patient_encounters
|
||||
WHERE doctor_id = %d AND status = 1",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
// Revenue this month (if bills have doctor association)
|
||||
$consultation_fee = (float) get_user_meta( $user_id, 'consultation_fee', true );
|
||||
if ( $consultation_fee > 0 ) {
|
||||
$stats['revenue_this_month'] = $stats['appointments_this_month'] * $consultation_fee;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
894
src/includes/models/class-encounter.php
Normal file
894
src/includes/models/class-encounter.php
Normal file
@@ -0,0 +1,894 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Encounter Model
|
||||
*
|
||||
* Handles medical encounter operations and patient consultation data
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Models
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Models;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Encounter
|
||||
*
|
||||
* Model for handling patient encounters (medical consultations)
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Encounter {
|
||||
|
||||
/**
|
||||
* Database table name
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private static $table_name = 'kc_patient_encounters';
|
||||
|
||||
/**
|
||||
* Encounter ID
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $id;
|
||||
|
||||
/**
|
||||
* Encounter data
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $data = array();
|
||||
|
||||
/**
|
||||
* Required fields for encounter creation
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $required_fields = array(
|
||||
'encounter_date',
|
||||
'clinic_id',
|
||||
'doctor_id',
|
||||
'patient_id'
|
||||
);
|
||||
|
||||
/**
|
||||
* Valid encounter statuses
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $valid_statuses = array(
|
||||
0 => 'draft',
|
||||
1 => 'completed',
|
||||
2 => 'cancelled'
|
||||
);
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param int|array $encounter_id_or_data Encounter ID or data array
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct( $encounter_id_or_data = null ) {
|
||||
if ( is_numeric( $encounter_id_or_data ) ) {
|
||||
$this->id = (int) $encounter_id_or_data;
|
||||
$this->load_data();
|
||||
} elseif ( is_array( $encounter_id_or_data ) ) {
|
||||
$this->data = $encounter_id_or_data;
|
||||
$this->id = isset( $this->data['id'] ) ? (int) $this->data['id'] : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load encounter data from database
|
||||
*
|
||||
* @return bool True on success, false on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function load_data() {
|
||||
if ( ! $this->id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$encounter_data = self::get_encounter_full_data( $this->id );
|
||||
|
||||
if ( $encounter_data ) {
|
||||
$this->data = $encounter_data;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new encounter
|
||||
*
|
||||
* @param array $encounter_data Encounter data
|
||||
* @return int|WP_Error Encounter ID on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function create( $encounter_data ) {
|
||||
// Validate required fields
|
||||
$validation = self::validate_encounter_data( $encounter_data );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
// Prepare data for insertion
|
||||
$insert_data = array(
|
||||
'encounter_date' => sanitize_text_field( $encounter_data['encounter_date'] ),
|
||||
'clinic_id' => (int) $encounter_data['clinic_id'],
|
||||
'doctor_id' => (int) $encounter_data['doctor_id'],
|
||||
'patient_id' => (int) $encounter_data['patient_id'],
|
||||
'appointment_id' => isset( $encounter_data['appointment_id'] ) ? (int) $encounter_data['appointment_id'] : null,
|
||||
'description' => isset( $encounter_data['description'] ) ? sanitize_textarea_field( $encounter_data['description'] ) : '',
|
||||
'status' => isset( $encounter_data['status'] ) ? (int) $encounter_data['status'] : 0,
|
||||
'added_by' => get_current_user_id(),
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
'template_id' => isset( $encounter_data['template_id'] ) ? (int) $encounter_data['template_id'] : null
|
||||
);
|
||||
|
||||
$result = $wpdb->insert( $table, $insert_data );
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'encounter_creation_failed',
|
||||
'Failed to create encounter: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
$encounter_id = $wpdb->insert_id;
|
||||
|
||||
// Create medical history entries if provided
|
||||
if ( ! empty( $encounter_data['medical_history'] ) && is_array( $encounter_data['medical_history'] ) ) {
|
||||
self::create_medical_history_entries( $encounter_id, $encounter_data['medical_history'] );
|
||||
}
|
||||
|
||||
return $encounter_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update encounter data
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @param array $encounter_data Updated encounter data
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function update( $encounter_id, $encounter_data ) {
|
||||
if ( ! self::exists( $encounter_id ) ) {
|
||||
return new \WP_Error(
|
||||
'encounter_not_found',
|
||||
'Encounter not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
// Prepare update data
|
||||
$update_data = array();
|
||||
$allowed_fields = array(
|
||||
'encounter_date', 'clinic_id', 'doctor_id', 'patient_id',
|
||||
'appointment_id', 'description', 'status', 'template_id'
|
||||
);
|
||||
|
||||
foreach ( $allowed_fields as $field ) {
|
||||
if ( isset( $encounter_data[ $field ] ) ) {
|
||||
$value = $encounter_data[ $field ];
|
||||
|
||||
switch ( $field ) {
|
||||
case 'clinic_id':
|
||||
case 'doctor_id':
|
||||
case 'patient_id':
|
||||
case 'appointment_id':
|
||||
case 'status':
|
||||
case 'template_id':
|
||||
$update_data[ $field ] = (int) $value;
|
||||
break;
|
||||
case 'description':
|
||||
$update_data[ $field ] = sanitize_textarea_field( $value );
|
||||
break;
|
||||
default:
|
||||
$update_data[ $field ] = sanitize_text_field( $value );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $update_data ) ) {
|
||||
return new \WP_Error(
|
||||
'no_data_to_update',
|
||||
'No valid data provided for update',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$result = $wpdb->update(
|
||||
$table,
|
||||
$update_data,
|
||||
array( 'id' => $encounter_id ),
|
||||
null,
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'encounter_update_failed',
|
||||
'Failed to update encounter: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an encounter
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function delete( $encounter_id ) {
|
||||
if ( ! self::exists( $encounter_id ) ) {
|
||||
return new \WP_Error(
|
||||
'encounter_not_found',
|
||||
'Encounter not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check if encounter can be deleted (has prescriptions or bills)
|
||||
if ( self::has_dependencies( $encounter_id ) ) {
|
||||
return new \WP_Error(
|
||||
'encounter_has_dependencies',
|
||||
'Cannot delete encounter with associated prescriptions or bills',
|
||||
array( 'status' => 409 )
|
||||
);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// Delete medical history entries first
|
||||
$wpdb->delete(
|
||||
"{$wpdb->prefix}kc_medical_history",
|
||||
array( 'encounter_id' => $encounter_id ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
// Delete encounter
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
$result = $wpdb->delete(
|
||||
$table,
|
||||
array( 'id' => $encounter_id ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'encounter_deletion_failed',
|
||||
'Failed to delete encounter: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encounter by ID
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @return array|null Encounter data or null if not found
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_by_id( $encounter_id ) {
|
||||
return self::get_encounter_full_data( $encounter_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all encounters with optional filtering
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return array Array of encounter data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_all( $args = array() ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$defaults = array(
|
||||
'clinic_id' => null,
|
||||
'doctor_id' => null,
|
||||
'patient_id' => null,
|
||||
'status' => null,
|
||||
'date_from' => null,
|
||||
'date_to' => null,
|
||||
'search' => '',
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
'orderby' => 'encounter_date',
|
||||
'order' => 'DESC'
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$where_clauses = array( '1=1' );
|
||||
$where_values = array();
|
||||
|
||||
// Clinic filter
|
||||
if ( ! is_null( $args['clinic_id'] ) ) {
|
||||
$where_clauses[] = 'e.clinic_id = %d';
|
||||
$where_values[] = $args['clinic_id'];
|
||||
}
|
||||
|
||||
// Doctor filter
|
||||
if ( ! is_null( $args['doctor_id'] ) ) {
|
||||
$where_clauses[] = 'e.doctor_id = %d';
|
||||
$where_values[] = $args['doctor_id'];
|
||||
}
|
||||
|
||||
// Patient filter
|
||||
if ( ! is_null( $args['patient_id'] ) ) {
|
||||
$where_clauses[] = 'e.patient_id = %d';
|
||||
$where_values[] = $args['patient_id'];
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ( ! is_null( $args['status'] ) ) {
|
||||
$where_clauses[] = 'e.status = %d';
|
||||
$where_values[] = $args['status'];
|
||||
}
|
||||
|
||||
// Date range filters
|
||||
if ( ! is_null( $args['date_from'] ) ) {
|
||||
$where_clauses[] = 'e.encounter_date >= %s';
|
||||
$where_values[] = $args['date_from'];
|
||||
}
|
||||
|
||||
if ( ! is_null( $args['date_to'] ) ) {
|
||||
$where_clauses[] = 'e.encounter_date <= %s';
|
||||
$where_values[] = $args['date_to'];
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if ( ! empty( $args['search'] ) ) {
|
||||
$where_clauses[] = '(p.first_name LIKE %s OR p.last_name LIKE %s OR d.first_name LIKE %s OR d.last_name LIKE %s OR e.description LIKE %s)';
|
||||
$search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%';
|
||||
$where_values = array_merge( $where_values, array_fill( 0, 5, $search_term ) );
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
// Build query
|
||||
$query = "SELECT e.*,
|
||||
c.name as clinic_name,
|
||||
CONCAT(p.first_name, ' ', p.last_name) as patient_name,
|
||||
p.user_email as patient_email,
|
||||
CONCAT(d.first_name, ' ', d.last_name) as doctor_name,
|
||||
d.user_email as doctor_email,
|
||||
CONCAT(ab.first_name, ' ', ab.last_name) as added_by_name
|
||||
FROM {$table} e
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON e.clinic_id = c.id
|
||||
LEFT JOIN {$wpdb->prefix}users p ON e.patient_id = p.ID
|
||||
LEFT JOIN {$wpdb->prefix}users d ON e.doctor_id = d.ID
|
||||
LEFT JOIN {$wpdb->prefix}users ab ON e.added_by = ab.ID
|
||||
WHERE {$where_sql}";
|
||||
|
||||
$query .= sprintf( ' ORDER BY e.%s %s',
|
||||
sanitize_sql_orderby( $args['orderby'] ),
|
||||
sanitize_sql_orderby( $args['order'] )
|
||||
);
|
||||
|
||||
$query .= $wpdb->prepare( ' LIMIT %d OFFSET %d', $args['limit'], $args['offset'] );
|
||||
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
|
||||
$encounters = $wpdb->get_results( $query, ARRAY_A );
|
||||
|
||||
return array_map( array( self::class, 'format_encounter_data' ), $encounters );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encounter full data with related entities
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @return array|null Full encounter data or null if not found
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_encounter_full_data( $encounter_id ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$encounter = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT e.*,
|
||||
c.name as clinic_name, c.address as clinic_address,
|
||||
CONCAT(p.first_name, ' ', p.last_name) as patient_name,
|
||||
p.user_email as patient_email,
|
||||
CONCAT(d.first_name, ' ', d.last_name) as doctor_name,
|
||||
d.user_email as doctor_email,
|
||||
CONCAT(ab.first_name, ' ', ab.last_name) as added_by_name
|
||||
FROM {$table} e
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON e.clinic_id = c.id
|
||||
LEFT JOIN {$wpdb->prefix}users p ON e.patient_id = p.ID
|
||||
LEFT JOIN {$wpdb->prefix}users d ON e.doctor_id = d.ID
|
||||
LEFT JOIN {$wpdb->prefix}users ab ON e.added_by = ab.ID
|
||||
WHERE e.id = %d",
|
||||
$encounter_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! $encounter ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::format_encounter_data( $encounter );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encounter prescriptions
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @return array Array of prescriptions
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_prescriptions( $encounter_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$prescriptions = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT p.*,
|
||||
CONCAT(ab.first_name, ' ', ab.last_name) as added_by_name
|
||||
FROM {$wpdb->prefix}kc_prescription p
|
||||
LEFT JOIN {$wpdb->prefix}users ab ON p.added_by = ab.ID
|
||||
WHERE p.encounter_id = %d
|
||||
ORDER BY p.created_at ASC",
|
||||
$encounter_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map( function( $prescription ) {
|
||||
return array(
|
||||
'id' => (int) $prescription['id'],
|
||||
'name' => $prescription['name'],
|
||||
'frequency' => $prescription['frequency'],
|
||||
'duration' => $prescription['duration'],
|
||||
'instruction' => $prescription['instruction'],
|
||||
'patient_id' => (int) $prescription['patient_id'],
|
||||
'added_by' => (int) $prescription['added_by'],
|
||||
'added_by_name' => $prescription['added_by_name'],
|
||||
'created_at' => $prescription['created_at'],
|
||||
'is_from_template' => (bool) $prescription['is_from_template']
|
||||
);
|
||||
}, $prescriptions );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encounter medical history
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @return array Array of medical history entries
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_medical_history( $encounter_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$history = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT mh.*,
|
||||
CONCAT(ab.first_name, ' ', ab.last_name) as added_by_name
|
||||
FROM {$wpdb->prefix}kc_medical_history mh
|
||||
LEFT JOIN {$wpdb->prefix}users ab ON mh.added_by = ab.ID
|
||||
WHERE mh.encounter_id = %d
|
||||
ORDER BY mh.created_at ASC",
|
||||
$encounter_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map( function( $entry ) {
|
||||
return array(
|
||||
'id' => (int) $entry['id'],
|
||||
'type' => $entry['type'],
|
||||
'title' => $entry['title'],
|
||||
'patient_id' => (int) $entry['patient_id'],
|
||||
'added_by' => (int) $entry['added_by'],
|
||||
'added_by_name' => $entry['added_by_name'],
|
||||
'created_at' => $entry['created_at']
|
||||
);
|
||||
}, $history );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encounter bills
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @return array Array of bills
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_bills( $encounter_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$bills = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_bills
|
||||
WHERE encounter_id = %d
|
||||
ORDER BY created_at DESC",
|
||||
$encounter_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map( function( $bill ) {
|
||||
return array(
|
||||
'id' => (int) $bill['id'],
|
||||
'title' => $bill['title'],
|
||||
'total_amount' => (float) $bill['total_amount'],
|
||||
'discount' => (float) $bill['discount'],
|
||||
'actual_amount' => (float) $bill['actual_amount'],
|
||||
'status' => (int) $bill['status'],
|
||||
'payment_status' => $bill['payment_status'],
|
||||
'created_at' => $bill['created_at'],
|
||||
'clinic_id' => (int) $bill['clinic_id'],
|
||||
'appointment_id' => (int) $bill['appointment_id']
|
||||
);
|
||||
}, $bills );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add prescription to encounter
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @param array $prescription_data Prescription data
|
||||
* @return int|WP_Error Prescription ID on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function add_prescription( $encounter_id, $prescription_data ) {
|
||||
if ( ! self::exists( $encounter_id ) ) {
|
||||
return new \WP_Error(
|
||||
'encounter_not_found',
|
||||
'Encounter not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
$encounter = self::get_by_id( $encounter_id );
|
||||
|
||||
return Prescription::create( array_merge( $prescription_data, array(
|
||||
'encounter_id' => $encounter_id,
|
||||
'patient_id' => $encounter['patient']['id']
|
||||
) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encounter exists
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @return bool True if exists, false otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function exists( $encounter_id ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE id = %d",
|
||||
$encounter_id
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encounter has dependencies (prescriptions, bills, etc.)
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @return bool True if has dependencies, false otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function has_dependencies( $encounter_id ) {
|
||||
global $wpdb;
|
||||
|
||||
// Check prescriptions
|
||||
$prescriptions_count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_prescription WHERE encounter_id = %d",
|
||||
$encounter_id
|
||||
)
|
||||
);
|
||||
|
||||
if ( (int) $prescriptions_count > 0 ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check bills
|
||||
$bills_count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_bills WHERE encounter_id = %d",
|
||||
$encounter_id
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $bills_count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate encounter data
|
||||
*
|
||||
* @param array $encounter_data Encounter data to validate
|
||||
* @return bool|WP_Error True if valid, WP_Error if invalid
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_encounter_data( $encounter_data ) {
|
||||
$errors = array();
|
||||
|
||||
// Check required fields
|
||||
foreach ( self::$required_fields as $field ) {
|
||||
if ( empty( $encounter_data[ $field ] ) ) {
|
||||
$errors[] = "Field '{$field}' is required";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate date format
|
||||
if ( ! empty( $encounter_data['encounter_date'] ) ) {
|
||||
$date = \DateTime::createFromFormat( 'Y-m-d', $encounter_data['encounter_date'] );
|
||||
if ( ! $date || $date->format( 'Y-m-d' ) !== $encounter_data['encounter_date'] ) {
|
||||
$errors[] = 'Invalid encounter date format. Use YYYY-MM-DD';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate entities exist
|
||||
if ( ! empty( $encounter_data['clinic_id'] ) &&
|
||||
! Clinic::exists( $encounter_data['clinic_id'] ) ) {
|
||||
$errors[] = 'Clinic not found';
|
||||
}
|
||||
|
||||
if ( ! empty( $encounter_data['doctor_id'] ) &&
|
||||
! Doctor::exists( $encounter_data['doctor_id'] ) ) {
|
||||
$errors[] = 'Doctor not found';
|
||||
}
|
||||
|
||||
if ( ! empty( $encounter_data['patient_id'] ) &&
|
||||
! Patient::exists( $encounter_data['patient_id'] ) ) {
|
||||
$errors[] = 'Patient not found';
|
||||
}
|
||||
|
||||
// Validate appointment exists if provided
|
||||
if ( ! empty( $encounter_data['appointment_id'] ) &&
|
||||
! Appointment::exists( $encounter_data['appointment_id'] ) ) {
|
||||
$errors[] = 'Appointment not found';
|
||||
}
|
||||
|
||||
// Validate status
|
||||
if ( isset( $encounter_data['status'] ) &&
|
||||
! array_key_exists( $encounter_data['status'], self::$valid_statuses ) ) {
|
||||
$errors[] = 'Invalid status value';
|
||||
}
|
||||
|
||||
if ( ! empty( $errors ) ) {
|
||||
return new \WP_Error(
|
||||
'encounter_validation_failed',
|
||||
'Encounter validation failed',
|
||||
array(
|
||||
'status' => 400,
|
||||
'errors' => $errors
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format encounter data for API response
|
||||
*
|
||||
* @param array $encounter_data Raw encounter data
|
||||
* @return array Formatted encounter data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function format_encounter_data( $encounter_data ) {
|
||||
if ( ! $encounter_data ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$formatted = array(
|
||||
'id' => (int) $encounter_data['id'],
|
||||
'encounter_date' => $encounter_data['encounter_date'],
|
||||
'description' => $encounter_data['description'],
|
||||
'status' => (int) $encounter_data['status'],
|
||||
'status_text' => self::$valid_statuses[ $encounter_data['status'] ] ?? 'unknown',
|
||||
'created_at' => $encounter_data['created_at'],
|
||||
'clinic' => array(
|
||||
'id' => (int) $encounter_data['clinic_id'],
|
||||
'name' => $encounter_data['clinic_name'] ?? '',
|
||||
'address' => $encounter_data['clinic_address'] ?? ''
|
||||
),
|
||||
'patient' => array(
|
||||
'id' => (int) $encounter_data['patient_id'],
|
||||
'name' => $encounter_data['patient_name'] ?? '',
|
||||
'email' => $encounter_data['patient_email'] ?? ''
|
||||
),
|
||||
'doctor' => array(
|
||||
'id' => (int) $encounter_data['doctor_id'],
|
||||
'name' => $encounter_data['doctor_name'] ?? '',
|
||||
'email' => $encounter_data['doctor_email'] ?? ''
|
||||
),
|
||||
'appointment_id' => isset( $encounter_data['appointment_id'] ) ? (int) $encounter_data['appointment_id'] : null,
|
||||
'template_id' => isset( $encounter_data['template_id'] ) ? (int) $encounter_data['template_id'] : null,
|
||||
'added_by' => array(
|
||||
'id' => (int) $encounter_data['added_by'],
|
||||
'name' => $encounter_data['added_by_name'] ?? ''
|
||||
)
|
||||
);
|
||||
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create medical history entries for encounter
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @param array $history_entries Array of history entries
|
||||
* @return bool True on success
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function create_medical_history_entries( $encounter_id, $history_entries ) {
|
||||
global $wpdb;
|
||||
|
||||
$encounter = self::get_by_id( $encounter_id );
|
||||
if ( ! $encounter ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ( $history_entries as $entry ) {
|
||||
$wpdb->insert(
|
||||
"{$wpdb->prefix}kc_medical_history",
|
||||
array(
|
||||
'encounter_id' => $encounter_id,
|
||||
'patient_id' => $encounter['patient']['id'],
|
||||
'type' => sanitize_text_field( $entry['type'] ),
|
||||
'title' => sanitize_text_field( $entry['title'] ),
|
||||
'added_by' => get_current_user_id(),
|
||||
'created_at' => current_time( 'mysql' )
|
||||
),
|
||||
array( '%d', '%d', '%s', '%s', '%d', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encounter statistics
|
||||
*
|
||||
* @param array $filters Optional filters
|
||||
* @return array Encounter statistics
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_statistics( $filters = array() ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$where_clauses = array( '1=1' );
|
||||
$where_values = array();
|
||||
|
||||
if ( ! empty( $filters['clinic_id'] ) ) {
|
||||
$where_clauses[] = 'clinic_id = %d';
|
||||
$where_values[] = $filters['clinic_id'];
|
||||
}
|
||||
|
||||
if ( ! empty( $filters['doctor_id'] ) ) {
|
||||
$where_clauses[] = 'doctor_id = %d';
|
||||
$where_values[] = $filters['doctor_id'];
|
||||
}
|
||||
|
||||
if ( ! empty( $filters['patient_id'] ) ) {
|
||||
$where_clauses[] = 'patient_id = %d';
|
||||
$where_values[] = $filters['patient_id'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$stats = array(
|
||||
'total_encounters' => 0,
|
||||
'draft_encounters' => 0,
|
||||
'completed_encounters' => 0,
|
||||
'cancelled_encounters' => 0,
|
||||
'encounters_today' => 0,
|
||||
'encounters_this_week' => 0,
|
||||
'encounters_this_month' => 0,
|
||||
'avg_encounters_per_day' => 0
|
||||
);
|
||||
|
||||
// Total encounters
|
||||
$query = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['total_encounters'] = (int) $wpdb->get_var( $query );
|
||||
|
||||
// Encounters by status
|
||||
foreach ( self::$valid_statuses as $status_id => $status_name ) {
|
||||
$status_where = $where_clauses;
|
||||
$status_where[] = 'status = %d';
|
||||
$status_values = array_merge( $where_values, array( $status_id ) );
|
||||
|
||||
$query = $wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $status_where ),
|
||||
$status_values
|
||||
);
|
||||
|
||||
$stats[ $status_name . '_encounters' ] = (int) $wpdb->get_var( $query );
|
||||
}
|
||||
|
||||
// Encounters today
|
||||
$today_where = array_merge( $where_clauses, array( 'encounter_date = CURDATE()' ) );
|
||||
$query = "SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $today_where );
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['encounters_today'] = (int) $wpdb->get_var( $query );
|
||||
|
||||
// Encounters this week
|
||||
$week_where = array_merge( $where_clauses, array(
|
||||
'WEEK(encounter_date) = WEEK(CURDATE())',
|
||||
'YEAR(encounter_date) = YEAR(CURDATE())'
|
||||
) );
|
||||
$query = "SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $week_where );
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['encounters_this_week'] = (int) $wpdb->get_var( $query );
|
||||
|
||||
// Encounters this month
|
||||
$month_where = array_merge( $where_clauses, array(
|
||||
'MONTH(encounter_date) = MONTH(CURDATE())',
|
||||
'YEAR(encounter_date) = YEAR(CURDATE())'
|
||||
) );
|
||||
$query = "SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $month_where );
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['encounters_this_month'] = (int) $wpdb->get_var( $query );
|
||||
|
||||
// Calculate average encounters per day (last 30 days)
|
||||
if ( $stats['total_encounters'] > 0 ) {
|
||||
$days_active = $wpdb->get_var(
|
||||
"SELECT DATEDIFF(MAX(encounter_date), MIN(encounter_date)) + 1
|
||||
FROM {$table}
|
||||
WHERE encounter_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)"
|
||||
);
|
||||
|
||||
if ( $days_active > 0 ) {
|
||||
$stats['avg_encounters_per_day'] = round( $stats['encounters_this_month'] / min( $days_active, 30 ), 2 );
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
825
src/includes/models/class-patient.php
Normal file
825
src/includes/models/class-patient.php
Normal file
@@ -0,0 +1,825 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Patient Model
|
||||
*
|
||||
* Handles patient entity operations and medical data management
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Models
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Models;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Patient
|
||||
*
|
||||
* Model for handling patient data, medical history and clinic associations
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Patient {
|
||||
|
||||
/**
|
||||
* Patient user ID (wp_users table)
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $user_id;
|
||||
|
||||
/**
|
||||
* Patient data
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $data = array();
|
||||
|
||||
/**
|
||||
* Required fields for patient registration
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $required_fields = array(
|
||||
'first_name',
|
||||
'last_name',
|
||||
'user_email',
|
||||
'birth_date',
|
||||
'gender'
|
||||
);
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param int|array $user_id_or_data Patient user ID or data array
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct( $user_id_or_data = null ) {
|
||||
if ( is_numeric( $user_id_or_data ) ) {
|
||||
$this->user_id = (int) $user_id_or_data;
|
||||
$this->load_data();
|
||||
} elseif ( is_array( $user_id_or_data ) ) {
|
||||
$this->data = $user_id_or_data;
|
||||
$this->user_id = isset( $this->data['user_id'] ) ? (int) $this->data['user_id'] : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load patient data from database
|
||||
*
|
||||
* @return bool True on success, false on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function load_data() {
|
||||
if ( ! $this->user_id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$patient_data = self::get_patient_full_data( $this->user_id );
|
||||
|
||||
if ( $patient_data ) {
|
||||
$this->data = $patient_data;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new patient
|
||||
*
|
||||
* @param array $patient_data Patient data
|
||||
* @return int|WP_Error Patient user ID on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function create( $patient_data ) {
|
||||
// Validate required fields
|
||||
$validation = self::validate_patient_data( $patient_data );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
// Create WordPress user
|
||||
$user_data = array(
|
||||
'user_login' => self::generate_unique_username( $patient_data ),
|
||||
'user_email' => sanitize_email( $patient_data['user_email'] ),
|
||||
'first_name' => sanitize_text_field( $patient_data['first_name'] ),
|
||||
'last_name' => sanitize_text_field( $patient_data['last_name'] ),
|
||||
'role' => 'kivicare_patient',
|
||||
'user_pass' => isset( $patient_data['user_pass'] ) ? $patient_data['user_pass'] : wp_generate_password()
|
||||
);
|
||||
|
||||
$user_id = wp_insert_user( $user_data );
|
||||
|
||||
if ( is_wp_error( $user_id ) ) {
|
||||
return new \WP_Error(
|
||||
'patient_creation_failed',
|
||||
'Failed to create patient user: ' . $user_id->get_error_message(),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
// Add patient meta data
|
||||
$meta_fields = array(
|
||||
'birth_date' => sanitize_text_field( $patient_data['birth_date'] ),
|
||||
'gender' => sanitize_text_field( $patient_data['gender'] ),
|
||||
'mobile_number' => isset( $patient_data['mobile_number'] ) ? sanitize_text_field( $patient_data['mobile_number'] ) : '',
|
||||
'address' => isset( $patient_data['address'] ) ? sanitize_textarea_field( $patient_data['address'] ) : '',
|
||||
'city' => isset( $patient_data['city'] ) ? sanitize_text_field( $patient_data['city'] ) : '',
|
||||
'state' => isset( $patient_data['state'] ) ? sanitize_text_field( $patient_data['state'] ) : '',
|
||||
'country' => isset( $patient_data['country'] ) ? sanitize_text_field( $patient_data['country'] ) : '',
|
||||
'postal_code' => isset( $patient_data['postal_code'] ) ? sanitize_text_field( $patient_data['postal_code'] ) : '',
|
||||
'blood_group' => isset( $patient_data['blood_group'] ) ? sanitize_text_field( $patient_data['blood_group'] ) : '',
|
||||
'emergency_contact' => isset( $patient_data['emergency_contact'] ) ? sanitize_text_field( $patient_data['emergency_contact'] ) : '',
|
||||
'medical_notes' => isset( $patient_data['medical_notes'] ) ? sanitize_textarea_field( $patient_data['medical_notes'] ) : '',
|
||||
'patient_registration_date' => current_time( 'mysql' )
|
||||
);
|
||||
|
||||
foreach ( $meta_fields as $meta_key => $meta_value ) {
|
||||
update_user_meta( $user_id, $meta_key, $meta_value );
|
||||
}
|
||||
|
||||
// Create clinic mapping if clinic_id provided
|
||||
if ( ! empty( $patient_data['clinic_id'] ) ) {
|
||||
self::assign_to_clinic( $user_id, $patient_data['clinic_id'] );
|
||||
}
|
||||
|
||||
return $user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update patient data
|
||||
*
|
||||
* @param int $user_id Patient user ID
|
||||
* @param array $patient_data Updated patient data
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function update( $user_id, $patient_data ) {
|
||||
if ( ! self::exists( $user_id ) ) {
|
||||
return new \WP_Error(
|
||||
'patient_not_found',
|
||||
'Patient not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Update user data
|
||||
$user_update_data = array( 'ID' => $user_id );
|
||||
$user_fields = array( 'first_name', 'last_name', 'user_email' );
|
||||
|
||||
foreach ( $user_fields as $field ) {
|
||||
if ( isset( $patient_data[ $field ] ) ) {
|
||||
$user_update_data[ $field ] = sanitize_text_field( $patient_data[ $field ] );
|
||||
}
|
||||
}
|
||||
|
||||
if ( count( $user_update_data ) > 1 ) {
|
||||
$result = wp_update_user( $user_update_data );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return new \WP_Error(
|
||||
'patient_update_failed',
|
||||
'Failed to update patient: ' . $result->get_error_message(),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update meta fields
|
||||
$meta_fields = array(
|
||||
'birth_date', 'gender', 'mobile_number', 'address', 'city',
|
||||
'state', 'country', 'postal_code', 'blood_group',
|
||||
'emergency_contact', 'medical_notes'
|
||||
);
|
||||
|
||||
foreach ( $meta_fields as $meta_key ) {
|
||||
if ( isset( $patient_data[ $meta_key ] ) ) {
|
||||
$value = $patient_data[ $meta_key ];
|
||||
|
||||
if ( in_array( $meta_key, array( 'address', 'medical_notes' ) ) ) {
|
||||
$value = sanitize_textarea_field( $value );
|
||||
} else {
|
||||
$value = sanitize_text_field( $value );
|
||||
}
|
||||
|
||||
update_user_meta( $user_id, $meta_key, $value );
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a patient (soft delete - deactivate user)
|
||||
*
|
||||
* @param int $user_id Patient user ID
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function delete( $user_id ) {
|
||||
if ( ! self::exists( $user_id ) ) {
|
||||
return new \WP_Error(
|
||||
'patient_not_found',
|
||||
'Patient not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check for dependencies
|
||||
if ( self::has_dependencies( $user_id ) ) {
|
||||
return new \WP_Error(
|
||||
'patient_has_dependencies',
|
||||
'Cannot delete patient with existing appointments or medical records',
|
||||
array( 'status' => 409 )
|
||||
);
|
||||
}
|
||||
|
||||
// Soft delete - set user status to inactive
|
||||
update_user_meta( $user_id, 'patient_status', 'inactive' );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient by ID
|
||||
*
|
||||
* @param int $user_id Patient user ID
|
||||
* @return array|null Patient data or null if not found
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_by_id( $user_id ) {
|
||||
if ( ! self::exists( $user_id ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::get_patient_full_data( $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all patients with optional filtering
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return array Array of patient data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_all( $args = array() ) {
|
||||
$defaults = array(
|
||||
'clinic_id' => null,
|
||||
'status' => 'active',
|
||||
'search' => '',
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
'orderby' => 'display_name',
|
||||
'order' => 'ASC'
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$user_query_args = array(
|
||||
'role' => 'kivicare_patient',
|
||||
'number' => $args['limit'],
|
||||
'offset' => $args['offset'],
|
||||
'orderby' => $args['orderby'],
|
||||
'order' => $args['order'],
|
||||
'fields' => 'ID'
|
||||
);
|
||||
|
||||
// Add search
|
||||
if ( ! empty( $args['search'] ) ) {
|
||||
$user_query_args['search'] = '*' . $args['search'] . '*';
|
||||
$user_query_args['search_columns'] = array( 'user_login', 'user_email', 'display_name' );
|
||||
}
|
||||
|
||||
// Add status filter via meta query
|
||||
if ( ! empty( $args['status'] ) ) {
|
||||
$user_query_args['meta_query'] = array(
|
||||
'relation' => 'OR',
|
||||
array(
|
||||
'key' => 'patient_status',
|
||||
'value' => $args['status'],
|
||||
'compare' => '='
|
||||
),
|
||||
array(
|
||||
'key' => 'patient_status',
|
||||
'compare' => 'NOT EXISTS'
|
||||
)
|
||||
);
|
||||
|
||||
if ( $args['status'] === 'active' ) {
|
||||
$user_query_args['meta_query'][1]['value'] = 'active';
|
||||
}
|
||||
}
|
||||
|
||||
$user_query = new \WP_User_Query( $user_query_args );
|
||||
$user_ids = $user_query->get_results();
|
||||
|
||||
$patients = array();
|
||||
foreach ( $user_ids as $user_id ) {
|
||||
$patient_data = self::get_patient_full_data( $user_id );
|
||||
|
||||
// Filter by clinic if specified
|
||||
if ( ! is_null( $args['clinic_id'] ) &&
|
||||
(int) $patient_data['clinic_id'] !== (int) $args['clinic_id'] ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $patient_data ) {
|
||||
$patients[] = $patient_data;
|
||||
}
|
||||
}
|
||||
|
||||
return $patients;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient full data with clinic information
|
||||
*
|
||||
* @param int $user_id Patient user ID
|
||||
* @return array|null Full patient data or null if not found
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_patient_full_data( $user_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$user = get_user_by( 'id', $user_id );
|
||||
if ( ! $user || ! in_array( 'kivicare_patient', $user->roles ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get clinic mapping
|
||||
$clinic_mapping = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT pcm.clinic_id, c.name as clinic_name
|
||||
FROM {$wpdb->prefix}kc_patient_clinic_mappings pcm
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON pcm.clinic_id = c.id
|
||||
WHERE pcm.patient_id = %d
|
||||
LIMIT 1",
|
||||
$user_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
// Get patient meta data
|
||||
$patient_data = array(
|
||||
'user_id' => $user_id,
|
||||
'username' => $user->user_login,
|
||||
'email' => $user->user_email,
|
||||
'first_name' => $user->first_name,
|
||||
'last_name' => $user->last_name,
|
||||
'display_name' => $user->display_name,
|
||||
'birth_date' => get_user_meta( $user_id, 'birth_date', true ),
|
||||
'gender' => get_user_meta( $user_id, 'gender', true ),
|
||||
'mobile_number' => get_user_meta( $user_id, 'mobile_number', true ),
|
||||
'address' => get_user_meta( $user_id, 'address', true ),
|
||||
'city' => get_user_meta( $user_id, 'city', true ),
|
||||
'state' => get_user_meta( $user_id, 'state', true ),
|
||||
'country' => get_user_meta( $user_id, 'country', true ),
|
||||
'postal_code' => get_user_meta( $user_id, 'postal_code', true ),
|
||||
'blood_group' => get_user_meta( $user_id, 'blood_group', true ),
|
||||
'emergency_contact' => get_user_meta( $user_id, 'emergency_contact', true ),
|
||||
'medical_notes' => get_user_meta( $user_id, 'medical_notes', true ),
|
||||
'status' => get_user_meta( $user_id, 'patient_status', true ) ?: 'active',
|
||||
'registration_date' => get_user_meta( $user_id, 'patient_registration_date', true ),
|
||||
'clinic_id' => $clinic_mapping ? (int) $clinic_mapping['clinic_id'] : null,
|
||||
'clinic_name' => $clinic_mapping ? $clinic_mapping['clinic_name'] : null,
|
||||
'age' => self::calculate_age( get_user_meta( $user_id, 'birth_date', true ) ),
|
||||
'total_appointments' => self::get_appointment_count( $user_id ),
|
||||
'last_visit' => self::get_last_visit_date( $user_id )
|
||||
);
|
||||
|
||||
return $patient_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient medical history
|
||||
*
|
||||
* @param int $user_id Patient user ID
|
||||
* @param array $args Query arguments
|
||||
* @return array Patient medical history
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_medical_history( $user_id, $args = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
$defaults = array(
|
||||
'type' => null,
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
'orderby' => 'created_at',
|
||||
'order' => 'DESC'
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$where_clauses = array( 'patient_id = %d' );
|
||||
$where_values = array( $user_id );
|
||||
|
||||
if ( ! is_null( $args['type'] ) ) {
|
||||
$where_clauses[] = 'type = %s';
|
||||
$where_values[] = $args['type'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$query = $wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_medical_history
|
||||
WHERE {$where_sql}
|
||||
ORDER BY {$args['orderby']} {$args['order']}
|
||||
LIMIT %d OFFSET %d",
|
||||
array_merge( $where_values, array( $args['limit'], $args['offset'] ) )
|
||||
);
|
||||
|
||||
$history = $wpdb->get_results( $query, ARRAY_A );
|
||||
|
||||
return array_map( function( $item ) {
|
||||
return array(
|
||||
'id' => (int) $item['id'],
|
||||
'encounter_id' => (int) $item['encounter_id'],
|
||||
'type' => $item['type'],
|
||||
'title' => $item['title'],
|
||||
'date' => $item['created_at'],
|
||||
'added_by' => (int) $item['added_by']
|
||||
);
|
||||
}, $history );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient encounters
|
||||
*
|
||||
* @param int $user_id Patient user ID
|
||||
* @param array $args Query arguments
|
||||
* @return array Patient encounters
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_encounters( $user_id, $args = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
$defaults = array(
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
'orderby' => 'encounter_date',
|
||||
'order' => 'DESC',
|
||||
'status' => null
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$where_clauses = array( 'patient_id = %d' );
|
||||
$where_values = array( $user_id );
|
||||
|
||||
if ( ! is_null( $args['status'] ) ) {
|
||||
$where_clauses[] = 'status = %d';
|
||||
$where_values[] = $args['status'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$query = $wpdb->prepare(
|
||||
"SELECT e.*, c.name as clinic_name,
|
||||
CONCAT(u.first_name, ' ', u.last_name) as doctor_name
|
||||
FROM {$wpdb->prefix}kc_patient_encounters e
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON e.clinic_id = c.id
|
||||
LEFT JOIN {$wpdb->prefix}users u ON e.doctor_id = u.ID
|
||||
WHERE {$where_sql}
|
||||
ORDER BY {$args['orderby']} {$args['order']}
|
||||
LIMIT %d OFFSET %d",
|
||||
array_merge( $where_values, array( $args['limit'], $args['offset'] ) )
|
||||
);
|
||||
|
||||
$encounters = $wpdb->get_results( $query, ARRAY_A );
|
||||
|
||||
return array_map( function( $encounter ) {
|
||||
return array(
|
||||
'id' => (int) $encounter['id'],
|
||||
'encounter_date' => $encounter['encounter_date'],
|
||||
'description' => $encounter['description'],
|
||||
'status' => (int) $encounter['status'],
|
||||
'clinic' => array(
|
||||
'id' => (int) $encounter['clinic_id'],
|
||||
'name' => $encounter['clinic_name']
|
||||
),
|
||||
'doctor' => array(
|
||||
'id' => (int) $encounter['doctor_id'],
|
||||
'name' => $encounter['doctor_name']
|
||||
),
|
||||
'appointment_id' => (int) $encounter['appointment_id'],
|
||||
'template_id' => (int) $encounter['template_id'],
|
||||
'created_at' => $encounter['created_at']
|
||||
);
|
||||
}, $encounters );
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign patient to clinic
|
||||
*
|
||||
* @param int $user_id Patient user ID
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function assign_to_clinic( $user_id, $clinic_id ) {
|
||||
global $wpdb;
|
||||
|
||||
// Check if clinic exists
|
||||
if ( ! Clinic::exists( $clinic_id ) ) {
|
||||
return new \WP_Error(
|
||||
'clinic_not_found',
|
||||
'Clinic not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check if mapping already exists
|
||||
$existing_mapping = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_patient_clinic_mappings
|
||||
WHERE patient_id = %d AND clinic_id = %d",
|
||||
$user_id, $clinic_id
|
||||
)
|
||||
);
|
||||
|
||||
if ( (int) $existing_mapping > 0 ) {
|
||||
return true; // Already assigned
|
||||
}
|
||||
|
||||
// Remove existing mappings (patient can only be assigned to one clinic at a time)
|
||||
$wpdb->delete(
|
||||
"{$wpdb->prefix}kc_patient_clinic_mappings",
|
||||
array( 'patient_id' => $user_id ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
// Create new mapping
|
||||
$result = $wpdb->insert(
|
||||
"{$wpdb->prefix}kc_patient_clinic_mappings",
|
||||
array(
|
||||
'patient_id' => $user_id,
|
||||
'clinic_id' => $clinic_id,
|
||||
'created_at' => current_time( 'mysql' )
|
||||
),
|
||||
array( '%d', '%d', '%s' )
|
||||
);
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'clinic_assignment_failed',
|
||||
'Failed to assign patient to clinic: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if patient exists
|
||||
*
|
||||
* @param int $user_id Patient user ID
|
||||
* @return bool True if exists, false otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function exists( $user_id ) {
|
||||
$user = get_user_by( 'id', $user_id );
|
||||
return $user && in_array( 'kivicare_patient', $user->roles );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if patient has dependencies (appointments, encounters, etc.)
|
||||
*
|
||||
* @param int $user_id Patient user ID
|
||||
* @return bool True if has dependencies, false otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function has_dependencies( $user_id ) {
|
||||
global $wpdb;
|
||||
|
||||
// Check appointments
|
||||
$appointments_count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments WHERE patient_id = %d",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
if ( (int) $appointments_count > 0 ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check encounters
|
||||
$encounters_count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_patient_encounters WHERE patient_id = %d",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $encounters_count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate patient data
|
||||
*
|
||||
* @param array $patient_data Patient data to validate
|
||||
* @return bool|WP_Error True if valid, WP_Error if invalid
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_patient_data( $patient_data ) {
|
||||
$errors = array();
|
||||
|
||||
// Check required fields
|
||||
foreach ( self::$required_fields as $field ) {
|
||||
if ( empty( $patient_data[ $field ] ) ) {
|
||||
$errors[] = "Field '{$field}' is required";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if ( ! empty( $patient_data['user_email'] ) && ! is_email( $patient_data['user_email'] ) ) {
|
||||
$errors[] = 'Invalid email format';
|
||||
}
|
||||
|
||||
// Check for duplicate email
|
||||
if ( ! empty( $patient_data['user_email'] ) ) {
|
||||
$existing_user = get_user_by( 'email', $patient_data['user_email'] );
|
||||
if ( $existing_user ) {
|
||||
$errors[] = 'A user with this email already exists';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate birth date format
|
||||
if ( ! empty( $patient_data['birth_date'] ) ) {
|
||||
$birth_date = \DateTime::createFromFormat( 'Y-m-d', $patient_data['birth_date'] );
|
||||
if ( ! $birth_date || $birth_date->format( 'Y-m-d' ) !== $patient_data['birth_date'] ) {
|
||||
$errors[] = 'Invalid birth date format. Use YYYY-MM-DD';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate gender
|
||||
if ( ! empty( $patient_data['gender'] ) && ! in_array( $patient_data['gender'], array( 'M', 'F', 'O' ) ) ) {
|
||||
$errors[] = 'Invalid gender. Use M, F, or O';
|
||||
}
|
||||
|
||||
if ( ! empty( $errors ) ) {
|
||||
return new \WP_Error(
|
||||
'patient_validation_failed',
|
||||
'Patient validation failed',
|
||||
array(
|
||||
'status' => 400,
|
||||
'errors' => $errors
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique username for patient
|
||||
*
|
||||
* @param array $patient_data Patient data
|
||||
* @return string Unique username
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function generate_unique_username( $patient_data ) {
|
||||
$base_username = strtolower(
|
||||
$patient_data['first_name'] . '.' . $patient_data['last_name']
|
||||
);
|
||||
$base_username = sanitize_user( $base_username );
|
||||
|
||||
$username = $base_username;
|
||||
$counter = 1;
|
||||
|
||||
while ( username_exists( $username ) ) {
|
||||
$username = $base_username . $counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
return $username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate patient age from birth date
|
||||
*
|
||||
* @param string $birth_date Birth date in Y-m-d format
|
||||
* @return int|null Age in years or null if invalid
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function calculate_age( $birth_date ) {
|
||||
if ( empty( $birth_date ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$birth = \DateTime::createFromFormat( 'Y-m-d', $birth_date );
|
||||
if ( ! $birth ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = new \DateTime();
|
||||
$age = $now->diff( $birth );
|
||||
|
||||
return $age->y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient appointment count
|
||||
*
|
||||
* @param int $user_id Patient user ID
|
||||
* @return int Total appointments
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_appointment_count( $user_id ) {
|
||||
global $wpdb;
|
||||
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments WHERE patient_id = %d",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient's last visit date
|
||||
*
|
||||
* @param int $user_id Patient user ID
|
||||
* @return string|null Last visit date or null
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_last_visit_date( $user_id ) {
|
||||
global $wpdb;
|
||||
|
||||
return $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT MAX(encounter_date) FROM {$wpdb->prefix}kc_patient_encounters WHERE patient_id = %d",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient statistics
|
||||
*
|
||||
* @param int $user_id Patient user ID
|
||||
* @return array Patient statistics
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_statistics( $user_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$stats = array(
|
||||
'total_appointments' => self::get_appointment_count( $user_id ),
|
||||
'completed_encounters' => 0,
|
||||
'pending_appointments' => 0,
|
||||
'total_prescriptions' => 0,
|
||||
'last_visit' => self::get_last_visit_date( $user_id ),
|
||||
'next_appointment' => null
|
||||
);
|
||||
|
||||
// Completed encounters
|
||||
$stats['completed_encounters'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_patient_encounters WHERE patient_id = %d AND status = 1",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
// Pending appointments
|
||||
$stats['pending_appointments'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments
|
||||
WHERE patient_id = %d AND status = 1 AND appointment_start_date >= CURDATE()",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
// Total prescriptions
|
||||
$stats['total_prescriptions'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_prescription WHERE patient_id = %d",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
// Next appointment
|
||||
$stats['next_appointment'] = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT MIN(appointment_start_date) FROM {$wpdb->prefix}kc_appointments
|
||||
WHERE patient_id = %d AND status = 1 AND appointment_start_date >= CURDATE()",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
804
src/includes/models/class-prescription.php
Normal file
804
src/includes/models/class-prescription.php
Normal file
@@ -0,0 +1,804 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Prescription Model
|
||||
*
|
||||
* Handles prescription operations and medication management
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Models
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Models;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Prescription
|
||||
*
|
||||
* Model for handling patient prescriptions and medication management
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Prescription {
|
||||
|
||||
/**
|
||||
* Database table name
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private static $table_name = 'kc_prescription';
|
||||
|
||||
/**
|
||||
* Prescription ID
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $id;
|
||||
|
||||
/**
|
||||
* Prescription data
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $data = array();
|
||||
|
||||
/**
|
||||
* Required fields for prescription creation
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $required_fields = array(
|
||||
'encounter_id',
|
||||
'patient_id',
|
||||
'name',
|
||||
'frequency',
|
||||
'duration'
|
||||
);
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param int|array $prescription_id_or_data Prescription ID or data array
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct( $prescription_id_or_data = null ) {
|
||||
if ( is_numeric( $prescription_id_or_data ) ) {
|
||||
$this->id = (int) $prescription_id_or_data;
|
||||
$this->load_data();
|
||||
} elseif ( is_array( $prescription_id_or_data ) ) {
|
||||
$this->data = $prescription_id_or_data;
|
||||
$this->id = isset( $this->data['id'] ) ? (int) $this->data['id'] : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load prescription data from database
|
||||
*
|
||||
* @return bool True on success, false on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function load_data() {
|
||||
if ( ! $this->id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$prescription_data = self::get_prescription_full_data( $this->id );
|
||||
|
||||
if ( $prescription_data ) {
|
||||
$this->data = $prescription_data;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new prescription
|
||||
*
|
||||
* @param array $prescription_data Prescription data
|
||||
* @return int|WP_Error Prescription ID on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function create( $prescription_data ) {
|
||||
// Validate required fields
|
||||
$validation = self::validate_prescription_data( $prescription_data );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
// Prepare data for insertion
|
||||
$insert_data = array(
|
||||
'encounter_id' => (int) $prescription_data['encounter_id'],
|
||||
'patient_id' => (int) $prescription_data['patient_id'],
|
||||
'name' => sanitize_textarea_field( $prescription_data['name'] ),
|
||||
'frequency' => sanitize_text_field( $prescription_data['frequency'] ),
|
||||
'duration' => sanitize_text_field( $prescription_data['duration'] ),
|
||||
'instruction' => isset( $prescription_data['instruction'] ) ? sanitize_textarea_field( $prescription_data['instruction'] ) : '',
|
||||
'added_by' => get_current_user_id(),
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
'is_from_template' => isset( $prescription_data['is_from_template'] ) ? (int) $prescription_data['is_from_template'] : 0
|
||||
);
|
||||
|
||||
$result = $wpdb->insert( $table, $insert_data );
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'prescription_creation_failed',
|
||||
'Failed to create prescription: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return $wpdb->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update prescription data
|
||||
*
|
||||
* @param int $prescription_id Prescription ID
|
||||
* @param array $prescription_data Updated prescription data
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function update( $prescription_id, $prescription_data ) {
|
||||
if ( ! self::exists( $prescription_id ) ) {
|
||||
return new \WP_Error(
|
||||
'prescription_not_found',
|
||||
'Prescription not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
// Prepare update data
|
||||
$update_data = array();
|
||||
$allowed_fields = array(
|
||||
'name', 'frequency', 'duration', 'instruction', 'is_from_template'
|
||||
);
|
||||
|
||||
foreach ( $allowed_fields as $field ) {
|
||||
if ( isset( $prescription_data[ $field ] ) ) {
|
||||
$value = $prescription_data[ $field ];
|
||||
|
||||
switch ( $field ) {
|
||||
case 'name':
|
||||
case 'instruction':
|
||||
$update_data[ $field ] = sanitize_textarea_field( $value );
|
||||
break;
|
||||
case 'is_from_template':
|
||||
$update_data[ $field ] = (int) $value;
|
||||
break;
|
||||
default:
|
||||
$update_data[ $field ] = sanitize_text_field( $value );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $update_data ) ) {
|
||||
return new \WP_Error(
|
||||
'no_data_to_update',
|
||||
'No valid data provided for update',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$result = $wpdb->update(
|
||||
$table,
|
||||
$update_data,
|
||||
array( 'id' => $prescription_id ),
|
||||
null,
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'prescription_update_failed',
|
||||
'Failed to update prescription: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a prescription
|
||||
*
|
||||
* @param int $prescription_id Prescription ID
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function delete( $prescription_id ) {
|
||||
if ( ! self::exists( $prescription_id ) ) {
|
||||
return new \WP_Error(
|
||||
'prescription_not_found',
|
||||
'Prescription not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$result = $wpdb->delete(
|
||||
$table,
|
||||
array( 'id' => $prescription_id ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'prescription_deletion_failed',
|
||||
'Failed to delete prescription: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prescription by ID
|
||||
*
|
||||
* @param int $prescription_id Prescription ID
|
||||
* @return array|null Prescription data or null if not found
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_by_id( $prescription_id ) {
|
||||
return self::get_prescription_full_data( $prescription_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all prescriptions with optional filtering
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return array Array of prescription data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_all( $args = array() ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$defaults = array(
|
||||
'encounter_id' => null,
|
||||
'patient_id' => null,
|
||||
'search' => '',
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
'orderby' => 'created_at',
|
||||
'order' => 'DESC'
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$where_clauses = array( '1=1' );
|
||||
$where_values = array();
|
||||
|
||||
// Encounter filter
|
||||
if ( ! is_null( $args['encounter_id'] ) ) {
|
||||
$where_clauses[] = 'p.encounter_id = %d';
|
||||
$where_values[] = $args['encounter_id'];
|
||||
}
|
||||
|
||||
// Patient filter
|
||||
if ( ! is_null( $args['patient_id'] ) ) {
|
||||
$where_clauses[] = 'p.patient_id = %d';
|
||||
$where_values[] = $args['patient_id'];
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if ( ! empty( $args['search'] ) ) {
|
||||
$where_clauses[] = '(p.name LIKE %s OR p.frequency LIKE %s OR p.instruction LIKE %s OR pt.first_name LIKE %s OR pt.last_name LIKE %s)';
|
||||
$search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%';
|
||||
$where_values = array_merge( $where_values, array_fill( 0, 5, $search_term ) );
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
// Build query
|
||||
$query = "SELECT p.*,
|
||||
CONCAT(pt.first_name, ' ', pt.last_name) as patient_name,
|
||||
pt.user_email as patient_email,
|
||||
CONCAT(ab.first_name, ' ', ab.last_name) as added_by_name,
|
||||
e.encounter_date
|
||||
FROM {$table} p
|
||||
LEFT JOIN {$wpdb->prefix}users pt ON p.patient_id = pt.ID
|
||||
LEFT JOIN {$wpdb->prefix}users ab ON p.added_by = ab.ID
|
||||
LEFT JOIN {$wpdb->prefix}kc_patient_encounters e ON p.encounter_id = e.id
|
||||
WHERE {$where_sql}";
|
||||
|
||||
$query .= sprintf( ' ORDER BY p.%s %s',
|
||||
sanitize_sql_orderby( $args['orderby'] ),
|
||||
sanitize_sql_orderby( $args['order'] )
|
||||
);
|
||||
|
||||
$query .= $wpdb->prepare( ' LIMIT %d OFFSET %d', $args['limit'], $args['offset'] );
|
||||
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
|
||||
$prescriptions = $wpdb->get_results( $query, ARRAY_A );
|
||||
|
||||
return array_map( array( self::class, 'format_prescription_data' ), $prescriptions );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prescription full data with related entities
|
||||
*
|
||||
* @param int $prescription_id Prescription ID
|
||||
* @return array|null Full prescription data or null if not found
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_prescription_full_data( $prescription_id ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$prescription = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT p.*,
|
||||
CONCAT(pt.first_name, ' ', pt.last_name) as patient_name,
|
||||
pt.user_email as patient_email,
|
||||
CONCAT(ab.first_name, ' ', ab.last_name) as added_by_name,
|
||||
e.encounter_date,
|
||||
CONCAT(d.first_name, ' ', d.last_name) as doctor_name
|
||||
FROM {$table} p
|
||||
LEFT JOIN {$wpdb->prefix}users pt ON p.patient_id = pt.ID
|
||||
LEFT JOIN {$wpdb->prefix}users ab ON p.added_by = ab.ID
|
||||
LEFT JOIN {$wpdb->prefix}kc_patient_encounters e ON p.encounter_id = e.id
|
||||
LEFT JOIN {$wpdb->prefix}users d ON e.doctor_id = d.ID
|
||||
WHERE p.id = %d",
|
||||
$prescription_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! $prescription ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::format_prescription_data( $prescription );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prescriptions by encounter
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @return array Array of prescriptions
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_by_encounter( $encounter_id ) {
|
||||
return self::get_all( array( 'encounter_id' => $encounter_id ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prescriptions by patient
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @param array $args Additional query arguments
|
||||
* @return array Array of prescriptions
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_by_patient( $patient_id, $args = array() ) {
|
||||
$args['patient_id'] = $patient_id;
|
||||
return self::get_all( $args );
|
||||
}
|
||||
|
||||
/**
|
||||
* Search medications (for autocomplete)
|
||||
*
|
||||
* @param string $search_term Search term
|
||||
* @param int $limit Limit results
|
||||
* @return array Array of medication names
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function search_medications( $search_term, $limit = 20 ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
if ( empty( $search_term ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$medications = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT DISTINCT name, COUNT(*) as usage_count
|
||||
FROM {$table}
|
||||
WHERE name LIKE %s
|
||||
GROUP BY name
|
||||
ORDER BY usage_count DESC, name ASC
|
||||
LIMIT %d",
|
||||
'%' . $wpdb->esc_like( $search_term ) . '%',
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map( function( $med ) {
|
||||
return array(
|
||||
'name' => $med['name'],
|
||||
'usage_count' => (int) $med['usage_count']
|
||||
);
|
||||
}, $medications );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get most prescribed medications
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return array Array of top medications
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_top_medications( $args = array() ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$defaults = array(
|
||||
'limit' => 10,
|
||||
'date_from' => null,
|
||||
'date_to' => null,
|
||||
'doctor_id' => null,
|
||||
'patient_id' => null
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$where_clauses = array( '1=1' );
|
||||
$where_values = array();
|
||||
|
||||
// Date range filters
|
||||
if ( ! is_null( $args['date_from'] ) ) {
|
||||
$where_clauses[] = 'p.created_at >= %s';
|
||||
$where_values[] = $args['date_from'] . ' 00:00:00';
|
||||
}
|
||||
|
||||
if ( ! is_null( $args['date_to'] ) ) {
|
||||
$where_clauses[] = 'p.created_at <= %s';
|
||||
$where_values[] = $args['date_to'] . ' 23:59:59';
|
||||
}
|
||||
|
||||
// Doctor filter (through encounter)
|
||||
if ( ! is_null( $args['doctor_id'] ) ) {
|
||||
$where_clauses[] = 'e.doctor_id = %d';
|
||||
$where_values[] = $args['doctor_id'];
|
||||
}
|
||||
|
||||
// Patient filter
|
||||
if ( ! is_null( $args['patient_id'] ) ) {
|
||||
$where_clauses[] = 'p.patient_id = %d';
|
||||
$where_values[] = $args['patient_id'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$query = "SELECT p.name,
|
||||
COUNT(*) as prescription_count,
|
||||
COUNT(DISTINCT p.patient_id) as unique_patients,
|
||||
MAX(p.created_at) as last_prescribed
|
||||
FROM {$table} p";
|
||||
|
||||
if ( ! is_null( $args['doctor_id'] ) ) {
|
||||
$query .= " LEFT JOIN {$wpdb->prefix}kc_patient_encounters e ON p.encounter_id = e.id";
|
||||
}
|
||||
|
||||
$query .= " WHERE {$where_sql}
|
||||
GROUP BY p.name
|
||||
ORDER BY prescription_count DESC
|
||||
LIMIT %d";
|
||||
|
||||
$where_values[] = $args['limit'];
|
||||
|
||||
$medications = $wpdb->get_results(
|
||||
$wpdb->prepare( $query, $where_values ),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map( function( $med ) {
|
||||
return array(
|
||||
'name' => $med['name'],
|
||||
'prescription_count' => (int) $med['prescription_count'],
|
||||
'unique_patients' => (int) $med['unique_patients'],
|
||||
'last_prescribed' => $med['last_prescribed']
|
||||
);
|
||||
}, $medications );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prescription templates
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return array Array of prescription templates
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_templates( $args = array() ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$defaults = array(
|
||||
'limit' => 50,
|
||||
'doctor_id' => null
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$where_clauses = array( 'is_from_template = 1' );
|
||||
$where_values = array();
|
||||
|
||||
// Doctor filter
|
||||
if ( ! is_null( $args['doctor_id'] ) ) {
|
||||
$where_clauses[] = 'added_by = %d';
|
||||
$where_values[] = $args['doctor_id'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$query = "SELECT name, frequency, duration, instruction,
|
||||
COUNT(*) as usage_count,
|
||||
MAX(created_at) as last_used
|
||||
FROM {$table}
|
||||
WHERE {$where_sql}
|
||||
GROUP BY name, frequency, duration, instruction
|
||||
ORDER BY usage_count DESC, last_used DESC
|
||||
LIMIT %d";
|
||||
|
||||
$where_values[] = $args['limit'];
|
||||
|
||||
$templates = $wpdb->get_results(
|
||||
empty( $where_values ) ?
|
||||
$wpdb->prepare( $query, $args['limit'] ) :
|
||||
$wpdb->prepare( $query, $where_values ),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map( function( $template ) {
|
||||
return array(
|
||||
'name' => $template['name'],
|
||||
'frequency' => $template['frequency'],
|
||||
'duration' => $template['duration'],
|
||||
'instruction' => $template['instruction'],
|
||||
'usage_count' => (int) $template['usage_count'],
|
||||
'last_used' => $template['last_used']
|
||||
);
|
||||
}, $templates );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if prescription exists
|
||||
*
|
||||
* @param int $prescription_id Prescription ID
|
||||
* @return bool True if exists, false otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function exists( $prescription_id ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE id = %d",
|
||||
$prescription_id
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate prescription data
|
||||
*
|
||||
* @param array $prescription_data Prescription data to validate
|
||||
* @return bool|WP_Error True if valid, WP_Error if invalid
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_prescription_data( $prescription_data ) {
|
||||
$errors = array();
|
||||
|
||||
// Check required fields
|
||||
foreach ( self::$required_fields as $field ) {
|
||||
if ( empty( $prescription_data[ $field ] ) ) {
|
||||
$errors[] = "Field '{$field}' is required";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate entities exist
|
||||
if ( ! empty( $prescription_data['encounter_id'] ) &&
|
||||
! Encounter::exists( $prescription_data['encounter_id'] ) ) {
|
||||
$errors[] = 'Encounter not found';
|
||||
}
|
||||
|
||||
if ( ! empty( $prescription_data['patient_id'] ) &&
|
||||
! Patient::exists( $prescription_data['patient_id'] ) ) {
|
||||
$errors[] = 'Patient not found';
|
||||
}
|
||||
|
||||
// Validate medication name
|
||||
if ( ! empty( $prescription_data['name'] ) ) {
|
||||
if ( strlen( $prescription_data['name'] ) > 500 ) {
|
||||
$errors[] = 'Medication name is too long (max 500 characters)';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate frequency format
|
||||
if ( ! empty( $prescription_data['frequency'] ) ) {
|
||||
if ( strlen( $prescription_data['frequency'] ) > 199 ) {
|
||||
$errors[] = 'Frequency is too long (max 199 characters)';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate duration format
|
||||
if ( ! empty( $prescription_data['duration'] ) ) {
|
||||
if ( strlen( $prescription_data['duration'] ) > 199 ) {
|
||||
$errors[] = 'Duration is too long (max 199 characters)';
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $errors ) ) {
|
||||
return new \WP_Error(
|
||||
'prescription_validation_failed',
|
||||
'Prescription validation failed',
|
||||
array(
|
||||
'status' => 400,
|
||||
'errors' => $errors
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format prescription data for API response
|
||||
*
|
||||
* @param array $prescription_data Raw prescription data
|
||||
* @return array Formatted prescription data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function format_prescription_data( $prescription_data ) {
|
||||
if ( ! $prescription_data ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$formatted = array(
|
||||
'id' => (int) $prescription_data['id'],
|
||||
'name' => $prescription_data['name'],
|
||||
'frequency' => $prescription_data['frequency'],
|
||||
'duration' => $prescription_data['duration'],
|
||||
'instruction' => $prescription_data['instruction'],
|
||||
'is_from_template' => (bool) $prescription_data['is_from_template'],
|
||||
'created_at' => $prescription_data['created_at'],
|
||||
'encounter' => array(
|
||||
'id' => (int) $prescription_data['encounter_id'],
|
||||
'encounter_date' => $prescription_data['encounter_date'] ?? null
|
||||
),
|
||||
'patient' => array(
|
||||
'id' => (int) $prescription_data['patient_id'],
|
||||
'name' => $prescription_data['patient_name'] ?? '',
|
||||
'email' => $prescription_data['patient_email'] ?? ''
|
||||
),
|
||||
'added_by' => array(
|
||||
'id' => (int) $prescription_data['added_by'],
|
||||
'name' => $prescription_data['added_by_name'] ?? ''
|
||||
),
|
||||
'doctor_name' => $prescription_data['doctor_name'] ?? ''
|
||||
);
|
||||
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prescription statistics
|
||||
*
|
||||
* @param array $filters Optional filters
|
||||
* @return array Prescription statistics
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_statistics( $filters = array() ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$where_clauses = array( '1=1' );
|
||||
$where_values = array();
|
||||
|
||||
if ( ! empty( $filters['patient_id'] ) ) {
|
||||
$where_clauses[] = 'patient_id = %d';
|
||||
$where_values[] = $filters['patient_id'];
|
||||
}
|
||||
|
||||
if ( ! empty( $filters['doctor_id'] ) ) {
|
||||
$where_clauses[] = 'added_by = %d';
|
||||
$where_values[] = $filters['doctor_id'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$stats = array(
|
||||
'total_prescriptions' => 0,
|
||||
'unique_medications' => 0,
|
||||
'prescriptions_today' => 0,
|
||||
'prescriptions_this_week' => 0,
|
||||
'prescriptions_this_month' => 0,
|
||||
'template_prescriptions' => 0,
|
||||
'avg_prescriptions_per_encounter' => 0
|
||||
);
|
||||
|
||||
// Total prescriptions
|
||||
$query = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['total_prescriptions'] = (int) $wpdb->get_var( $query );
|
||||
|
||||
// Unique medications
|
||||
$query = "SELECT COUNT(DISTINCT name) FROM {$table} WHERE {$where_sql}";
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['unique_medications'] = (int) $wpdb->get_var( $query );
|
||||
|
||||
// Prescriptions today
|
||||
$today_where = array_merge( $where_clauses, array( 'DATE(created_at) = CURDATE()' ) );
|
||||
$query = "SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $today_where );
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['prescriptions_today'] = (int) $wpdb->get_var( $query );
|
||||
|
||||
// Prescriptions this week
|
||||
$week_where = array_merge( $where_clauses, array(
|
||||
'WEEK(created_at) = WEEK(CURDATE())',
|
||||
'YEAR(created_at) = YEAR(CURDATE())'
|
||||
) );
|
||||
$query = "SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $week_where );
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['prescriptions_this_week'] = (int) $wpdb->get_var( $query );
|
||||
|
||||
// Prescriptions this month
|
||||
$month_where = array_merge( $where_clauses, array(
|
||||
'MONTH(created_at) = MONTH(CURDATE())',
|
||||
'YEAR(created_at) = YEAR(CURDATE())'
|
||||
) );
|
||||
$query = "SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $month_where );
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['prescriptions_this_month'] = (int) $wpdb->get_var( $query );
|
||||
|
||||
// Template prescriptions
|
||||
$template_where = array_merge( $where_clauses, array( 'is_from_template = 1' ) );
|
||||
$query = "SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $template_where );
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['template_prescriptions'] = (int) $wpdb->get_var( $query );
|
||||
|
||||
// Average prescriptions per encounter
|
||||
if ( $stats['total_prescriptions'] > 0 ) {
|
||||
$unique_encounters = $wpdb->get_var(
|
||||
"SELECT COUNT(DISTINCT encounter_id) FROM {$table} WHERE {$where_sql}"
|
||||
);
|
||||
if ( $unique_encounters > 0 ) {
|
||||
$stats['avg_prescriptions_per_encounter'] = round( $stats['total_prescriptions'] / $unique_encounters, 2 );
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
814
src/includes/models/class-service.php
Normal file
814
src/includes/models/class-service.php
Normal file
@@ -0,0 +1,814 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Service Model
|
||||
*
|
||||
* Handles medical service operations and pricing management
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Models
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Models;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Service
|
||||
*
|
||||
* Model for handling medical services, procedures and pricing
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Service {
|
||||
|
||||
/**
|
||||
* Database table name
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private static $table_name = 'kc_services';
|
||||
|
||||
/**
|
||||
* Service ID
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $id;
|
||||
|
||||
/**
|
||||
* Service data
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $data = array();
|
||||
|
||||
/**
|
||||
* Required fields for service creation
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $required_fields = array(
|
||||
'name',
|
||||
'type',
|
||||
'price'
|
||||
);
|
||||
|
||||
/**
|
||||
* Valid service types
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $valid_types = array(
|
||||
'consultation' => 'Consultation',
|
||||
'procedure' => 'Medical Procedure',
|
||||
'diagnostic' => 'Diagnostic Test',
|
||||
'therapy' => 'Therapy Session',
|
||||
'surgery' => 'Surgery',
|
||||
'vaccination' => 'Vaccination',
|
||||
'checkup' => 'Health Checkup',
|
||||
'other' => 'Other'
|
||||
);
|
||||
|
||||
/**
|
||||
* Valid service statuses
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $valid_statuses = array(
|
||||
1 => 'active',
|
||||
0 => 'inactive'
|
||||
);
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param int|array $service_id_or_data Service ID or data array
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function __construct( $service_id_or_data = null ) {
|
||||
if ( is_numeric( $service_id_or_data ) ) {
|
||||
$this->id = (int) $service_id_or_data;
|
||||
$this->load_data();
|
||||
} elseif ( is_array( $service_id_or_data ) ) {
|
||||
$this->data = $service_id_or_data;
|
||||
$this->id = isset( $this->data['id'] ) ? (int) $this->data['id'] : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load service data from database
|
||||
*
|
||||
* @return bool True on success, false on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function load_data() {
|
||||
if ( ! $this->id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$service_data = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE id = %d",
|
||||
$this->id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( $service_data ) {
|
||||
$this->data = self::format_service_data( $service_data );
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new service
|
||||
*
|
||||
* @param array $service_data Service data
|
||||
* @return int|WP_Error Service ID on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function create( $service_data ) {
|
||||
// Validate required fields
|
||||
$validation = self::validate_service_data( $service_data );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
// Prepare data for insertion
|
||||
$insert_data = array(
|
||||
'name' => sanitize_text_field( $service_data['name'] ),
|
||||
'type' => sanitize_text_field( $service_data['type'] ),
|
||||
'price' => number_format( (float) $service_data['price'], 2, '.', '' ),
|
||||
'description' => isset( $service_data['description'] ) ? sanitize_textarea_field( $service_data['description'] ) : '',
|
||||
'duration' => isset( $service_data['duration'] ) ? (int) $service_data['duration'] : null,
|
||||
'status' => isset( $service_data['status'] ) ? (int) $service_data['status'] : 1,
|
||||
'created_at' => current_time( 'mysql' )
|
||||
);
|
||||
|
||||
$result = $wpdb->insert( $table, $insert_data );
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'service_creation_failed',
|
||||
'Failed to create service: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return $wpdb->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update service data
|
||||
*
|
||||
* @param int $service_id Service ID
|
||||
* @param array $service_data Updated service data
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function update( $service_id, $service_data ) {
|
||||
if ( ! self::exists( $service_id ) ) {
|
||||
return new \WP_Error(
|
||||
'service_not_found',
|
||||
'Service not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
// Prepare update data
|
||||
$update_data = array();
|
||||
$allowed_fields = array(
|
||||
'name', 'type', 'price', 'description', 'duration', 'status'
|
||||
);
|
||||
|
||||
foreach ( $allowed_fields as $field ) {
|
||||
if ( isset( $service_data[ $field ] ) ) {
|
||||
$value = $service_data[ $field ];
|
||||
|
||||
switch ( $field ) {
|
||||
case 'price':
|
||||
$update_data[ $field ] = number_format( (float) $value, 2, '.', '' );
|
||||
break;
|
||||
case 'duration':
|
||||
case 'status':
|
||||
$update_data[ $field ] = (int) $value;
|
||||
break;
|
||||
case 'description':
|
||||
$update_data[ $field ] = sanitize_textarea_field( $value );
|
||||
break;
|
||||
case 'type':
|
||||
if ( array_key_exists( $value, self::$valid_types ) ) {
|
||||
$update_data[ $field ] = sanitize_text_field( $value );
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$update_data[ $field ] = sanitize_text_field( $value );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $update_data ) ) {
|
||||
return new \WP_Error(
|
||||
'no_data_to_update',
|
||||
'No valid data provided for update',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$result = $wpdb->update(
|
||||
$table,
|
||||
$update_data,
|
||||
array( 'id' => $service_id ),
|
||||
null,
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'service_update_failed',
|
||||
'Failed to update service: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a service
|
||||
*
|
||||
* @param int $service_id Service ID
|
||||
* @return bool|WP_Error True on success, WP_Error on failure
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function delete( $service_id ) {
|
||||
if ( ! self::exists( $service_id ) ) {
|
||||
return new \WP_Error(
|
||||
'service_not_found',
|
||||
'Service not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check if service is being used in appointments
|
||||
if ( self::has_dependencies( $service_id ) ) {
|
||||
return new \WP_Error(
|
||||
'service_has_dependencies',
|
||||
'Cannot delete service that is associated with appointments',
|
||||
array( 'status' => 409 )
|
||||
);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$result = $wpdb->delete(
|
||||
$table,
|
||||
array( 'id' => $service_id ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error(
|
||||
'service_deletion_failed',
|
||||
'Failed to delete service: ' . $wpdb->last_error,
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service by ID
|
||||
*
|
||||
* @param int $service_id Service ID
|
||||
* @return array|null Service data or null if not found
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_by_id( $service_id ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$service = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE id = %d",
|
||||
$service_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( $service ) {
|
||||
return self::format_service_data( $service );
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all services with optional filtering
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return array Array of service data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_all( $args = array() ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$defaults = array(
|
||||
'type' => null,
|
||||
'status' => 1, // Only active by default
|
||||
'search' => '',
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
'orderby' => 'name',
|
||||
'order' => 'ASC'
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$where_clauses = array( '1=1' );
|
||||
$where_values = array();
|
||||
|
||||
// Type filter
|
||||
if ( ! is_null( $args['type'] ) ) {
|
||||
$where_clauses[] = 'type = %s';
|
||||
$where_values[] = $args['type'];
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ( ! is_null( $args['status'] ) ) {
|
||||
$where_clauses[] = 'status = %d';
|
||||
$where_values[] = $args['status'];
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if ( ! empty( $args['search'] ) ) {
|
||||
$where_clauses[] = '(name LIKE %s OR description LIKE %s)';
|
||||
$search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%';
|
||||
$where_values[] = $search_term;
|
||||
$where_values[] = $search_term;
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
// Build query
|
||||
$query = "SELECT * FROM {$table} WHERE {$where_sql}";
|
||||
$query .= sprintf( ' ORDER BY %s %s',
|
||||
sanitize_sql_orderby( $args['orderby'] ),
|
||||
sanitize_sql_orderby( $args['order'] )
|
||||
);
|
||||
$query .= $wpdb->prepare( ' LIMIT %d OFFSET %d', $args['limit'], $args['offset'] );
|
||||
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
|
||||
$services = $wpdb->get_results( $query, ARRAY_A );
|
||||
|
||||
return array_map( array( self::class, 'format_service_data' ), $services );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get services by type
|
||||
*
|
||||
* @param string $type Service type
|
||||
* @param array $args Additional query arguments
|
||||
* @return array Array of services
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_by_type( $type, $args = array() ) {
|
||||
$args['type'] = $type;
|
||||
return self::get_all( $args );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get most popular services
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return array Array of popular services with usage stats
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_popular_services( $args = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
$defaults = array(
|
||||
'limit' => 10,
|
||||
'date_from' => null,
|
||||
'date_to' => null,
|
||||
'clinic_id' => null
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
$where_clauses = array( '1=1' );
|
||||
$where_values = array();
|
||||
|
||||
// Date range filters
|
||||
if ( ! is_null( $args['date_from'] ) ) {
|
||||
$where_clauses[] = 'a.created_at >= %s';
|
||||
$where_values[] = $args['date_from'] . ' 00:00:00';
|
||||
}
|
||||
|
||||
if ( ! is_null( $args['date_to'] ) ) {
|
||||
$where_clauses[] = 'a.created_at <= %s';
|
||||
$where_values[] = $args['date_to'] . ' 23:59:59';
|
||||
}
|
||||
|
||||
// Clinic filter
|
||||
if ( ! is_null( $args['clinic_id'] ) ) {
|
||||
$where_clauses[] = 'a.clinic_id = %d';
|
||||
$where_values[] = $args['clinic_id'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$query = "SELECT s.*,
|
||||
COUNT(asm.service_id) as usage_count,
|
||||
MAX(a.created_at) as last_used
|
||||
FROM {$wpdb->prefix}kc_services s
|
||||
LEFT JOIN {$wpdb->prefix}kc_appointment_service_mapping asm ON s.id = asm.service_id
|
||||
LEFT JOIN {$wpdb->prefix}kc_appointments a ON asm.appointment_id = a.id
|
||||
WHERE s.status = 1 AND {$where_sql}
|
||||
GROUP BY s.id
|
||||
ORDER BY usage_count DESC, s.name ASC
|
||||
LIMIT %d";
|
||||
|
||||
$where_values[] = $args['limit'];
|
||||
|
||||
$services = $wpdb->get_results(
|
||||
$wpdb->prepare( $query, $where_values ),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map( function( $service ) {
|
||||
$formatted = self::format_service_data( $service );
|
||||
$formatted['usage_count'] = (int) $service['usage_count'];
|
||||
$formatted['last_used'] = $service['last_used'];
|
||||
return $formatted;
|
||||
}, $services );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service pricing tiers (if implemented)
|
||||
*
|
||||
* @param int $service_id Service ID
|
||||
* @return array Service pricing information
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_pricing( $service_id ) {
|
||||
$service = self::get_by_id( $service_id );
|
||||
|
||||
if ( ! $service ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Basic pricing - could be extended for complex pricing models
|
||||
return array(
|
||||
'service_id' => $service['id'],
|
||||
'base_price' => $service['price'],
|
||||
'currency' => 'EUR', // Could be configurable
|
||||
'pricing_type' => 'fixed', // fixed, variable, tiered, etc.
|
||||
'includes_consultation' => $service['type'] === 'consultation'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search services (for autocomplete)
|
||||
*
|
||||
* @param string $search_term Search term
|
||||
* @param int $limit Limit results
|
||||
* @return array Array of service names
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function search_services( $search_term, $limit = 20 ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
if ( empty( $search_term ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$services = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, name, type, price
|
||||
FROM {$table}
|
||||
WHERE status = 1 AND (name LIKE %s OR description LIKE %s)
|
||||
ORDER BY name ASC
|
||||
LIMIT %d",
|
||||
'%' . $wpdb->esc_like( $search_term ) . '%',
|
||||
'%' . $wpdb->esc_like( $search_term ) . '%',
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map( function( $service ) {
|
||||
return array(
|
||||
'id' => (int) $service['id'],
|
||||
'name' => $service['name'],
|
||||
'type' => $service['type'],
|
||||
'type_text' => self::$valid_types[ $service['type'] ] ?? 'Unknown',
|
||||
'price' => (float) $service['price']
|
||||
);
|
||||
}, $services );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if service exists
|
||||
*
|
||||
* @param int $service_id Service ID
|
||||
* @return bool True if exists, false otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function exists( $service_id ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE id = %d",
|
||||
$service_id
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if service has dependencies (appointments)
|
||||
*
|
||||
* @param int $service_id Service ID
|
||||
* @return bool True if has dependencies, false otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function has_dependencies( $service_id ) {
|
||||
global $wpdb;
|
||||
|
||||
// Check appointment service mappings
|
||||
$mappings_count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointment_service_mapping WHERE service_id = %d",
|
||||
$service_id
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $mappings_count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate service data
|
||||
*
|
||||
* @param array $service_data Service data to validate
|
||||
* @return bool|WP_Error True if valid, WP_Error if invalid
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_service_data( $service_data ) {
|
||||
$errors = array();
|
||||
|
||||
// Check required fields
|
||||
foreach ( self::$required_fields as $field ) {
|
||||
if ( empty( $service_data[ $field ] ) ) {
|
||||
$errors[] = "Field '{$field}' is required";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate price
|
||||
if ( isset( $service_data['price'] ) ) {
|
||||
if ( ! is_numeric( $service_data['price'] ) || (float) $service_data['price'] < 0 ) {
|
||||
$errors[] = 'Price must be a non-negative number';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if ( isset( $service_data['type'] ) &&
|
||||
! array_key_exists( $service_data['type'], self::$valid_types ) ) {
|
||||
$errors[] = 'Invalid service type';
|
||||
}
|
||||
|
||||
// Validate duration
|
||||
if ( isset( $service_data['duration'] ) ) {
|
||||
if ( ! is_numeric( $service_data['duration'] ) || (int) $service_data['duration'] <= 0 ) {
|
||||
$errors[] = 'Duration must be a positive number (in minutes)';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate status
|
||||
if ( isset( $service_data['status'] ) &&
|
||||
! array_key_exists( $service_data['status'], self::$valid_statuses ) ) {
|
||||
$errors[] = 'Invalid status value';
|
||||
}
|
||||
|
||||
// Check for duplicate service name
|
||||
if ( ! empty( $service_data['name'] ) ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$existing_service = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE name = %s",
|
||||
$service_data['name']
|
||||
)
|
||||
);
|
||||
|
||||
if ( $existing_service ) {
|
||||
$errors[] = 'A service with this name already exists';
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $errors ) ) {
|
||||
return new \WP_Error(
|
||||
'service_validation_failed',
|
||||
'Service validation failed',
|
||||
array(
|
||||
'status' => 400,
|
||||
'errors' => $errors
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format service data for API response
|
||||
*
|
||||
* @param array $service_data Raw service data
|
||||
* @return array Formatted service data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function format_service_data( $service_data ) {
|
||||
if ( ! $service_data ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$formatted = array(
|
||||
'id' => (int) $service_data['id'],
|
||||
'name' => $service_data['name'],
|
||||
'type' => $service_data['type'],
|
||||
'type_text' => self::$valid_types[ $service_data['type'] ] ?? 'Unknown',
|
||||
'price' => (float) $service_data['price'],
|
||||
'description' => $service_data['description'] ?? '',
|
||||
'duration' => isset( $service_data['duration'] ) ? (int) $service_data['duration'] : null,
|
||||
'status' => (int) $service_data['status'],
|
||||
'status_text' => self::$valid_statuses[ $service_data['status'] ] ?? 'unknown',
|
||||
'created_at' => $service_data['created_at']
|
||||
);
|
||||
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service statistics
|
||||
*
|
||||
* @param array $filters Optional filters
|
||||
* @return array Service statistics
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_statistics( $filters = array() ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$where_clauses = array( '1=1' );
|
||||
$where_values = array();
|
||||
|
||||
if ( ! empty( $filters['type'] ) ) {
|
||||
$where_clauses[] = 'type = %s';
|
||||
$where_values[] = $filters['type'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$stats = array(
|
||||
'total_services' => 0,
|
||||
'active_services' => 0,
|
||||
'inactive_services' => 0,
|
||||
'services_by_type' => array(),
|
||||
'average_price' => 0,
|
||||
'price_range' => array(
|
||||
'min' => 0,
|
||||
'max' => 0
|
||||
),
|
||||
'most_expensive_service' => null,
|
||||
'least_expensive_service' => null
|
||||
);
|
||||
|
||||
// Total services
|
||||
$query = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['total_services'] = (int) $wpdb->get_var( $query );
|
||||
|
||||
// Services by status
|
||||
foreach ( array_keys( self::$valid_statuses ) as $status_id ) {
|
||||
$status_where = $where_clauses;
|
||||
$status_where[] = 'status = %d';
|
||||
$status_values = array_merge( $where_values, array( $status_id ) );
|
||||
|
||||
$query = $wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $status_where ),
|
||||
$status_values
|
||||
);
|
||||
|
||||
$count = (int) $wpdb->get_var( $query );
|
||||
$stats[ self::$valid_statuses[ $status_id ] . '_services' ] = $count;
|
||||
}
|
||||
|
||||
// Services by type
|
||||
foreach ( array_keys( self::$valid_types ) as $type ) {
|
||||
$type_where = $where_clauses;
|
||||
$type_where[] = 'type = %s';
|
||||
$type_where[] = 'status = 1'; // Only active services
|
||||
$type_values = array_merge( $where_values, array( $type ) );
|
||||
|
||||
$query = $wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE " . implode( ' AND ', $type_where ),
|
||||
$type_values
|
||||
);
|
||||
|
||||
$count = (int) $wpdb->get_var( $query );
|
||||
if ( $count > 0 ) {
|
||||
$stats['services_by_type'][ $type ] = array(
|
||||
'count' => $count,
|
||||
'name' => self::$valid_types[ $type ]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Price statistics (active services only)
|
||||
$price_where = $where_clauses;
|
||||
$price_where[] = 'status = 1';
|
||||
$price_where[] = 'price > 0';
|
||||
|
||||
// Average price
|
||||
$query = "SELECT AVG(CAST(price AS DECIMAL(10,2))) FROM {$table} WHERE " . implode( ' AND ', $price_where );
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$stats['average_price'] = round( (float) $wpdb->get_var( $query ) ?: 0, 2 );
|
||||
|
||||
// Price range
|
||||
$query = "SELECT MIN(CAST(price AS DECIMAL(10,2))), MAX(CAST(price AS DECIMAL(10,2))) FROM {$table} WHERE " . implode( ' AND ', $price_where );
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$price_range = $wpdb->get_row( $query, ARRAY_N );
|
||||
if ( $price_range ) {
|
||||
$stats['price_range']['min'] = (float) $price_range[0] ?: 0;
|
||||
$stats['price_range']['max'] = (float) $price_range[1] ?: 0;
|
||||
}
|
||||
|
||||
// Most and least expensive services
|
||||
if ( $stats['total_services'] > 0 ) {
|
||||
$query = "SELECT name, price FROM {$table} WHERE " . implode( ' AND ', $price_where ) . " ORDER BY CAST(price AS DECIMAL(10,2)) DESC LIMIT 1";
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$most_expensive = $wpdb->get_row( $query, ARRAY_A );
|
||||
if ( $most_expensive ) {
|
||||
$stats['most_expensive_service'] = array(
|
||||
'name' => $most_expensive['name'],
|
||||
'price' => (float) $most_expensive['price']
|
||||
);
|
||||
}
|
||||
|
||||
$query = "SELECT name, price FROM {$table} WHERE " . implode( ' AND ', $price_where ) . " ORDER BY CAST(price AS DECIMAL(10,2)) ASC LIMIT 1";
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$query = $wpdb->prepare( $query, $where_values );
|
||||
}
|
||||
$least_expensive = $wpdb->get_row( $query, ARRAY_A );
|
||||
if ( $least_expensive ) {
|
||||
$stats['least_expensive_service'] = array(
|
||||
'name' => $least_expensive['name'],
|
||||
'price' => (float) $least_expensive['price']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
848
src/includes/services/class-auth-service.php
Normal file
848
src/includes/services/class-auth-service.php
Normal file
@@ -0,0 +1,848 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Authentication Service
|
||||
*
|
||||
* Handles JWT authentication, user validation and security
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Services
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Services;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Auth_Service
|
||||
*
|
||||
* JWT Authentication service for KiviCare API
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Auth_Service {
|
||||
|
||||
/**
|
||||
* JWT Secret key
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private static $jwt_secret;
|
||||
|
||||
/**
|
||||
* Token expiration time (24 hours)
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private static $token_expiration = 86400;
|
||||
|
||||
/**
|
||||
* Refresh token expiration (7 days)
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private static $refresh_token_expiration = 604800;
|
||||
|
||||
/**
|
||||
* Valid KiviCare roles
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $valid_roles = array(
|
||||
'administrator',
|
||||
'kivicare_doctor',
|
||||
'kivicare_patient',
|
||||
'kivicare_receptionist'
|
||||
);
|
||||
|
||||
/**
|
||||
* Initialize the authentication service
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function init() {
|
||||
self::$jwt_secret = self::get_jwt_secret();
|
||||
|
||||
// Hook into WordPress authentication
|
||||
add_filter( 'determine_current_user', array( self::class, 'determine_current_user' ), 20 );
|
||||
add_filter( 'rest_authentication_errors', array( self::class, 'rest_authentication_errors' ) );
|
||||
|
||||
// Add custom headers
|
||||
add_action( 'rest_api_init', array( self::class, 'add_cors_headers' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate user with username/email and password
|
||||
*
|
||||
* @param string $username Username or email
|
||||
* @param string $password Password
|
||||
* @return array|WP_Error Authentication response or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function authenticate( $username, $password ) {
|
||||
// Input validation
|
||||
if ( empty( $username ) || empty( $password ) ) {
|
||||
return new \WP_Error(
|
||||
'missing_credentials',
|
||||
'Username and password are required',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Attempt to get user by username or email
|
||||
$user = self::get_user_by_login( $username );
|
||||
|
||||
if ( ! $user ) {
|
||||
return new \WP_Error(
|
||||
'invalid_username',
|
||||
'Invalid username or email address',
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if ( ! wp_check_password( $password, $user->user_pass, $user->ID ) ) {
|
||||
// Log failed login attempt
|
||||
self::log_failed_login( $user->ID, $username );
|
||||
|
||||
return new \WP_Error(
|
||||
'invalid_password',
|
||||
'Invalid password',
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user has valid KiviCare role
|
||||
if ( ! self::has_valid_role( $user ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'User does not have permission to access KiviCare API',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user account is active
|
||||
$user_status = get_user_meta( $user->ID, 'kivicare_user_status', true );
|
||||
if ( $user_status === 'inactive' || $user_status === 'suspended' ) {
|
||||
return new \WP_Error(
|
||||
'account_inactive',
|
||||
'User account is inactive or suspended',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
$access_token = self::generate_jwt_token( $user );
|
||||
$refresh_token = self::generate_refresh_token( $user );
|
||||
|
||||
// Update user login metadata
|
||||
self::update_login_metadata( $user->ID );
|
||||
|
||||
// Return authentication response
|
||||
return array(
|
||||
'success' => true,
|
||||
'data' => array(
|
||||
'user' => self::format_user_data( $user ),
|
||||
'access_token' => $access_token,
|
||||
'refresh_token' => $refresh_token,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => self::$token_expiration
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*
|
||||
* @param string $refresh_token Refresh token
|
||||
* @return array|WP_Error New tokens or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function refresh_token( $refresh_token ) {
|
||||
if ( empty( $refresh_token ) ) {
|
||||
return new \WP_Error(
|
||||
'missing_refresh_token',
|
||||
'Refresh token is required',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Validate refresh token
|
||||
$user_id = self::validate_refresh_token( $refresh_token );
|
||||
|
||||
if ( is_wp_error( $user_id ) ) {
|
||||
return $user_id;
|
||||
}
|
||||
|
||||
$user = get_user_by( 'id', $user_id );
|
||||
if ( ! $user ) {
|
||||
return new \WP_Error(
|
||||
'user_not_found',
|
||||
'User not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user still has valid permissions
|
||||
if ( ! self::has_valid_role( $user ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'User no longer has permission to access KiviCare API',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
$new_access_token = self::generate_jwt_token( $user );
|
||||
$new_refresh_token = self::generate_refresh_token( $user );
|
||||
|
||||
return array(
|
||||
'success' => true,
|
||||
'data' => array(
|
||||
'access_token' => $new_access_token,
|
||||
'refresh_token' => $new_refresh_token,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => self::$token_expiration
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JWT token and return user
|
||||
*
|
||||
* @param string $token JWT token
|
||||
* @return WP_User|WP_Error User object or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function validate_token( $token ) {
|
||||
if ( empty( $token ) ) {
|
||||
return new \WP_Error(
|
||||
'missing_token',
|
||||
'Authentication token is required',
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Remove 'Bearer ' prefix if present
|
||||
$token = str_replace( 'Bearer ', '', $token );
|
||||
|
||||
// Decode JWT token
|
||||
$payload = self::decode_jwt_token( $token );
|
||||
|
||||
if ( is_wp_error( $payload ) ) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
// Get user
|
||||
$user = get_user_by( 'id', $payload->user_id );
|
||||
|
||||
if ( ! $user ) {
|
||||
return new \WP_Error(
|
||||
'user_not_found',
|
||||
'User not found',
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
// Validate user still has proper role
|
||||
if ( ! self::has_valid_role( $user ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'User no longer has permission to access KiviCare API',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
return $user;
|
||||
|
||||
} catch ( Exception $e ) {
|
||||
return new \WP_Error(
|
||||
'token_validation_failed',
|
||||
'Token validation failed: ' . $e->getMessage(),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user by invalidating tokens
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @return bool Success status
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function logout( $user_id ) {
|
||||
// Invalidate all refresh tokens for this user
|
||||
delete_user_meta( $user_id, 'kivicare_refresh_token' );
|
||||
delete_user_meta( $user_id, 'kivicare_refresh_token_expires' );
|
||||
|
||||
// Update logout timestamp
|
||||
update_user_meta( $user_id, 'kivicare_last_logout', current_time( 'mysql' ) );
|
||||
|
||||
// Log logout action
|
||||
self::log_user_action( $user_id, 'logout' );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current authenticated user
|
||||
*
|
||||
* @return WP_User|null Current user or null
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_current_user() {
|
||||
$user_id = get_current_user_id();
|
||||
return $user_id ? get_user_by( 'id', $user_id ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user has specific capability
|
||||
*
|
||||
* @param string $capability Capability to check
|
||||
* @param array $args Additional arguments
|
||||
* @return bool True if user has capability
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function current_user_can( $capability, $args = array() ) {
|
||||
$user = self::get_current_user();
|
||||
|
||||
if ( ! $user ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check WordPress capability
|
||||
if ( ! empty( $args ) ) {
|
||||
return user_can( $user, $capability, ...$args );
|
||||
}
|
||||
|
||||
return user_can( $user, $capability );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT token for user
|
||||
*
|
||||
* @param WP_User $user User object
|
||||
* @return string JWT token
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function generate_jwt_token( $user ) {
|
||||
$issued_at = time();
|
||||
$expiration = $issued_at + self::$token_expiration;
|
||||
|
||||
$payload = array(
|
||||
'iss' => get_site_url(),
|
||||
'aud' => 'kivicare-api',
|
||||
'iat' => $issued_at,
|
||||
'exp' => $expiration,
|
||||
'user_id' => $user->ID,
|
||||
'username' => $user->user_login,
|
||||
'email' => $user->user_email,
|
||||
'roles' => $user->roles,
|
||||
'jti' => wp_generate_uuid4()
|
||||
);
|
||||
|
||||
return self::encode_jwt_token( $payload );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token for user
|
||||
*
|
||||
* @param WP_User $user User object
|
||||
* @return string Refresh token
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function generate_refresh_token( $user ) {
|
||||
$refresh_token = wp_generate_uuid4();
|
||||
$expires_at = time() + self::$refresh_token_expiration;
|
||||
|
||||
// Store refresh token in user meta
|
||||
update_user_meta( $user->ID, 'kivicare_refresh_token', wp_hash( $refresh_token ) );
|
||||
update_user_meta( $user->ID, 'kivicare_refresh_token_expires', $expires_at );
|
||||
|
||||
return $refresh_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate refresh token
|
||||
*
|
||||
* @param string $refresh_token Refresh token
|
||||
* @return int|WP_Error User ID or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_refresh_token( $refresh_token ) {
|
||||
// Find user with this refresh token
|
||||
$users = get_users( array(
|
||||
'meta_key' => 'kivicare_refresh_token',
|
||||
'meta_value' => wp_hash( $refresh_token ),
|
||||
'number' => 1
|
||||
) );
|
||||
|
||||
if ( empty( $users ) ) {
|
||||
return new \WP_Error(
|
||||
'invalid_refresh_token',
|
||||
'Invalid refresh token',
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
$user = $users[0];
|
||||
$expires_at = get_user_meta( $user->ID, 'kivicare_refresh_token_expires', true );
|
||||
|
||||
// Check if token is expired
|
||||
if ( $expires_at && time() > $expires_at ) {
|
||||
// Clean up expired token
|
||||
delete_user_meta( $user->ID, 'kivicare_refresh_token' );
|
||||
delete_user_meta( $user->ID, 'kivicare_refresh_token_expires' );
|
||||
|
||||
return new \WP_Error(
|
||||
'refresh_token_expired',
|
||||
'Refresh token has expired',
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
return $user->ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode JWT token
|
||||
*
|
||||
* @param array $payload Token payload
|
||||
* @return string Encoded token
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function encode_jwt_token( $payload ) {
|
||||
$header = array(
|
||||
'typ' => 'JWT',
|
||||
'alg' => 'HS256'
|
||||
);
|
||||
|
||||
$header_encoded = self::base64url_encode( wp_json_encode( $header ) );
|
||||
$payload_encoded = self::base64url_encode( wp_json_encode( $payload ) );
|
||||
|
||||
$signature = hash_hmac(
|
||||
'sha256',
|
||||
$header_encoded . '.' . $payload_encoded,
|
||||
self::$jwt_secret,
|
||||
true
|
||||
);
|
||||
|
||||
$signature_encoded = self::base64url_encode( $signature );
|
||||
|
||||
return $header_encoded . '.' . $payload_encoded . '.' . $signature_encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT token
|
||||
*
|
||||
* @param string $token JWT token
|
||||
* @return object|WP_Error Token payload or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function decode_jwt_token( $token ) {
|
||||
$parts = explode( '.', $token );
|
||||
|
||||
if ( count( $parts ) !== 3 ) {
|
||||
return new \WP_Error(
|
||||
'invalid_token_format',
|
||||
'Invalid token format',
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
list( $header_encoded, $payload_encoded, $signature_encoded ) = $parts;
|
||||
|
||||
// Verify signature
|
||||
$signature = self::base64url_decode( $signature_encoded );
|
||||
$expected_signature = hash_hmac(
|
||||
'sha256',
|
||||
$header_encoded . '.' . $payload_encoded,
|
||||
self::$jwt_secret,
|
||||
true
|
||||
);
|
||||
|
||||
if ( ! hash_equals( $signature, $expected_signature ) ) {
|
||||
return new \WP_Error(
|
||||
'invalid_token_signature',
|
||||
'Invalid token signature',
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
$payload = json_decode( self::base64url_decode( $payload_encoded ) );
|
||||
|
||||
if ( ! $payload ) {
|
||||
return new \WP_Error(
|
||||
'invalid_token_payload',
|
||||
'Invalid token payload',
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if ( isset( $payload->exp ) && time() > $payload->exp ) {
|
||||
return new \WP_Error(
|
||||
'token_expired',
|
||||
'Token has expired',
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64URL encode
|
||||
*
|
||||
* @param string $data Data to encode
|
||||
* @return string Encoded data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function base64url_encode( $data ) {
|
||||
return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64URL decode
|
||||
*
|
||||
* @param string $data Data to decode
|
||||
* @return string Decoded data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function base64url_decode( $data ) {
|
||||
return base64_decode( str_pad( strtr( $data, '-_', '+/' ), strlen( $data ) % 4, '=', STR_PAD_RIGHT ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create JWT secret
|
||||
*
|
||||
* @return string JWT secret
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_jwt_secret() {
|
||||
// Try to get from wp-config constant first
|
||||
if ( defined( 'KIVICARE_JWT_SECRET' ) && ! empty( KIVICARE_JWT_SECRET ) ) {
|
||||
return KIVICARE_JWT_SECRET;
|
||||
}
|
||||
|
||||
// Get from options
|
||||
$secret = get_option( 'kivicare_jwt_secret' );
|
||||
|
||||
if ( empty( $secret ) ) {
|
||||
// Generate new secret
|
||||
$secret = wp_generate_password( 64, true, true );
|
||||
update_option( 'kivicare_jwt_secret', $secret );
|
||||
}
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by username or email
|
||||
*
|
||||
* @param string $login Username or email
|
||||
* @return WP_User|false User object or false
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_user_by_login( $login ) {
|
||||
// Try by username first
|
||||
$user = get_user_by( 'login', $login );
|
||||
|
||||
// If not found, try by email
|
||||
if ( ! $user && is_email( $login ) ) {
|
||||
$user = get_user_by( 'email', $login );
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has valid KiviCare role
|
||||
*
|
||||
* @param WP_User $user User object
|
||||
* @return bool True if has valid role
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function has_valid_role( $user ) {
|
||||
$user_roles = $user->roles;
|
||||
return ! empty( array_intersect( $user_roles, self::$valid_roles ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Format user data for API response
|
||||
*
|
||||
* @param WP_User $user User object
|
||||
* @return array Formatted user data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function format_user_data( $user ) {
|
||||
return array(
|
||||
'id' => $user->ID,
|
||||
'username' => $user->user_login,
|
||||
'email' => $user->user_email,
|
||||
'first_name' => $user->first_name,
|
||||
'last_name' => $user->last_name,
|
||||
'display_name' => $user->display_name,
|
||||
'roles' => $user->roles,
|
||||
'primary_role' => self::get_primary_role( $user ),
|
||||
'avatar_url' => get_avatar_url( $user->ID ),
|
||||
'registered_date' => $user->user_registered
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get primary KiviCare role for user
|
||||
*
|
||||
* @param WP_User $user User object
|
||||
* @return string Primary role
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_primary_role( $user ) {
|
||||
$kivicare_roles = array_intersect( $user->roles, self::$valid_roles );
|
||||
|
||||
// Priority order for KiviCare roles
|
||||
$role_priority = array(
|
||||
'administrator',
|
||||
'kivicare_doctor',
|
||||
'kivicare_receptionist',
|
||||
'kivicare_patient'
|
||||
);
|
||||
|
||||
foreach ( $role_priority as $role ) {
|
||||
if ( in_array( $role, $kivicare_roles ) ) {
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
|
||||
return 'kivicare_patient'; // Default fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user login metadata
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function update_login_metadata( $user_id ) {
|
||||
update_user_meta( $user_id, 'kivicare_last_login', current_time( 'mysql' ) );
|
||||
update_user_meta( $user_id, 'kivicare_login_count', (int) get_user_meta( $user_id, 'kivicare_login_count', true ) + 1 );
|
||||
update_user_meta( $user_id, 'kivicare_last_ip', self::get_client_ip() );
|
||||
|
||||
// Log successful login
|
||||
self::log_user_action( $user_id, 'login' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Log failed login attempt
|
||||
*
|
||||
* @param int $user_id User ID (if found)
|
||||
* @param string $username Attempted username
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function log_failed_login( $user_id, $username ) {
|
||||
$log_data = array(
|
||||
'user_id' => $user_id,
|
||||
'username' => $username,
|
||||
'ip_address' => self::get_client_ip(),
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
'timestamp' => current_time( 'mysql' )
|
||||
);
|
||||
|
||||
// Could be extended to store in custom table or send alerts
|
||||
do_action( 'kivicare_failed_login', $log_data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Log user action
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @param string $action Action performed
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function log_user_action( $user_id, $action ) {
|
||||
$log_data = array(
|
||||
'user_id' => $user_id,
|
||||
'action' => $action,
|
||||
'ip_address' => self::get_client_ip(),
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
'timestamp' => current_time( 'mysql' )
|
||||
);
|
||||
|
||||
// Could be extended for audit logging
|
||||
do_action( 'kivicare_user_action', $log_data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address
|
||||
*
|
||||
* @return string IP address
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_client_ip() {
|
||||
$ip_keys = array(
|
||||
'HTTP_CF_CONNECTING_IP',
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_FORWARDED',
|
||||
'HTTP_X_CLUSTER_CLIENT_IP',
|
||||
'HTTP_FORWARDED_FOR',
|
||||
'HTTP_FORWARDED',
|
||||
'REMOTE_ADDR'
|
||||
);
|
||||
|
||||
foreach ( $ip_keys as $key ) {
|
||||
if ( ! empty( $_SERVER[ $key ] ) ) {
|
||||
$ip = $_SERVER[ $key ];
|
||||
if ( strpos( $ip, ',' ) !== false ) {
|
||||
$ip = explode( ',', $ip )[0];
|
||||
}
|
||||
$ip = trim( $ip );
|
||||
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* WordPress hook: Determine current user from JWT token
|
||||
*
|
||||
* @param int $user_id Current user ID
|
||||
* @return int User ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function determine_current_user( $user_id ) {
|
||||
// Skip if user is already determined
|
||||
if ( $user_id ) {
|
||||
return $user_id;
|
||||
}
|
||||
|
||||
// Only for REST API requests
|
||||
if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) {
|
||||
return $user_id;
|
||||
}
|
||||
|
||||
// Get authorization header
|
||||
$auth_header = self::get_authorization_header();
|
||||
|
||||
if ( empty( $auth_header ) ) {
|
||||
return $user_id;
|
||||
}
|
||||
|
||||
// Validate token
|
||||
$user = self::validate_token( $auth_header );
|
||||
|
||||
if ( is_wp_error( $user ) ) {
|
||||
return $user_id;
|
||||
}
|
||||
|
||||
return $user->ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* WordPress hook: REST authentication errors
|
||||
*
|
||||
* @param WP_Error|null|bool $result Previous result
|
||||
* @return WP_Error|null|bool Authentication result
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function rest_authentication_errors( $result ) {
|
||||
// Skip if already processed
|
||||
if ( ! empty( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Check if this is a KiviCare API request
|
||||
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
if ( strpos( $request_uri, '/wp-json/kivicare/v1' ) === false ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Allow authentication endpoints without token
|
||||
$public_endpoints = array(
|
||||
'/wp-json/kivicare/v1/auth/login',
|
||||
'/wp-json/kivicare/v1/auth/refresh'
|
||||
);
|
||||
|
||||
foreach ( $public_endpoints as $endpoint ) {
|
||||
if ( strpos( $request_uri, $endpoint ) !== false ) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
// Require authentication for all other KiviCare endpoints
|
||||
if ( ! get_current_user_id() ) {
|
||||
return new \WP_Error(
|
||||
'rest_not_logged_in',
|
||||
'You are not currently logged in.',
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization header
|
||||
*
|
||||
* @return string|null Authorization header
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_authorization_header() {
|
||||
$headers = array(
|
||||
'HTTP_AUTHORIZATION',
|
||||
'REDIRECT_HTTP_AUTHORIZATION'
|
||||
);
|
||||
|
||||
foreach ( $headers as $header ) {
|
||||
if ( ! empty( $_SERVER[ $header ] ) ) {
|
||||
return trim( $_SERVER[ $header ] );
|
||||
}
|
||||
}
|
||||
|
||||
// Check if using PHP-CGI
|
||||
if ( function_exists( 'apache_request_headers' ) ) {
|
||||
$apache_headers = apache_request_headers();
|
||||
if ( isset( $apache_headers['Authorization'] ) ) {
|
||||
return trim( $apache_headers['Authorization'] );
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add CORS headers for API requests
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function add_cors_headers() {
|
||||
// Allow specific origins (should be configured)
|
||||
$allowed_origins = apply_filters( 'kivicare_api_allowed_origins', array() );
|
||||
|
||||
if ( ! empty( $allowed_origins ) ) {
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if ( in_array( $origin, $allowed_origins ) ) {
|
||||
header( 'Access-Control-Allow-Origin: ' . $origin );
|
||||
}
|
||||
}
|
||||
|
||||
header( 'Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS' );
|
||||
header( 'Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce' );
|
||||
header( 'Access-Control-Allow-Credentials: true' );
|
||||
}
|
||||
}
|
||||
838
src/includes/services/class-permission-service.php
Normal file
838
src/includes/services/class-permission-service.php
Normal file
@@ -0,0 +1,838 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Permission Service
|
||||
*
|
||||
* Handles role-based access control and permission management
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Services
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Services;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Permission_Service
|
||||
*
|
||||
* Role-based permission system for KiviCare API
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Permission_Service {
|
||||
|
||||
/**
|
||||
* Permission matrix defining what each role can do
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $permission_matrix = array();
|
||||
|
||||
/**
|
||||
* Resource-specific permissions
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $resource_permissions = array();
|
||||
|
||||
/**
|
||||
* Initialize the permission service
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function init() {
|
||||
self::define_permission_matrix();
|
||||
self::define_resource_permissions();
|
||||
|
||||
// Hook into WordPress capability system
|
||||
add_filter( 'user_has_cap', array( self::class, 'user_has_cap' ), 10, 4 );
|
||||
|
||||
// Add custom capabilities on plugin activation
|
||||
add_action( 'init', array( self::class, 'add_kivicare_capabilities' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has permission for specific action
|
||||
*
|
||||
* @param int|WP_User $user User ID or user object
|
||||
* @param string $permission Permission to check
|
||||
* @param array $context Additional context (resource_id, clinic_id, etc.)
|
||||
* @return bool True if user has permission
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function has_permission( $user, $permission, $context = array() ) {
|
||||
if ( is_numeric( $user ) ) {
|
||||
$user = get_user_by( 'id', $user );
|
||||
}
|
||||
|
||||
if ( ! $user instanceof \WP_User ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Super admin has all permissions
|
||||
if ( is_super_admin( $user->ID ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get user's primary KiviCare role
|
||||
$primary_role = self::get_primary_kivicare_role( $user );
|
||||
|
||||
if ( ! $primary_role ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check basic permission matrix
|
||||
if ( ! self::role_has_permission( $primary_role, $permission ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply contextual restrictions
|
||||
return self::check_contextual_permissions( $user, $permission, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user has permission
|
||||
*
|
||||
* @param string $permission Permission to check
|
||||
* @param array $context Additional context
|
||||
* @return bool True if current user has permission
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function current_user_can( $permission, $context = array() ) {
|
||||
$user = wp_get_current_user();
|
||||
return $user && self::has_permission( $user, $permission, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for a user
|
||||
*
|
||||
* @param int|WP_User $user User ID or user object
|
||||
* @return array Array of permissions
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_user_permissions( $user ) {
|
||||
if ( is_numeric( $user ) ) {
|
||||
$user = get_user_by( 'id', $user );
|
||||
}
|
||||
|
||||
if ( ! $user instanceof \WP_User ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$primary_role = self::get_primary_kivicare_role( $user );
|
||||
|
||||
if ( ! $primary_role || ! isset( self::$permission_matrix[ $primary_role ] ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
return self::$permission_matrix[ $primary_role ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check clinic access permission
|
||||
*
|
||||
* @param int|WP_User $user User ID or user object
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return bool True if user has access to clinic
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function can_access_clinic( $user, $clinic_id ) {
|
||||
if ( is_numeric( $user ) ) {
|
||||
$user = get_user_by( 'id', $user );
|
||||
}
|
||||
|
||||
if ( ! $user instanceof \WP_User ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Administrator has access to all clinics
|
||||
if ( in_array( 'administrator', $user->roles ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$primary_role = self::get_primary_kivicare_role( $user );
|
||||
|
||||
switch ( $primary_role ) {
|
||||
case 'kivicare_doctor':
|
||||
return self::doctor_has_clinic_access( $user->ID, $clinic_id );
|
||||
|
||||
case 'kivicare_patient':
|
||||
return self::patient_has_clinic_access( $user->ID, $clinic_id );
|
||||
|
||||
case 'kivicare_receptionist':
|
||||
return self::receptionist_has_clinic_access( $user->ID, $clinic_id );
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check patient access permission
|
||||
*
|
||||
* @param int|WP_User $user User ID or user object
|
||||
* @param int $patient_id Patient ID
|
||||
* @return bool True if user can access patient data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function can_access_patient( $user, $patient_id ) {
|
||||
if ( is_numeric( $user ) ) {
|
||||
$user = get_user_by( 'id', $user );
|
||||
}
|
||||
|
||||
if ( ! $user instanceof \WP_User ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Administrator has access to all patients
|
||||
if ( in_array( 'administrator', $user->roles ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$primary_role = self::get_primary_kivicare_role( $user );
|
||||
|
||||
switch ( $primary_role ) {
|
||||
case 'kivicare_doctor':
|
||||
return self::doctor_can_access_patient( $user->ID, $patient_id );
|
||||
|
||||
case 'kivicare_patient':
|
||||
// Patients can only access their own data
|
||||
return $user->ID === $patient_id;
|
||||
|
||||
case 'kivicare_receptionist':
|
||||
return self::receptionist_can_access_patient( $user->ID, $patient_id );
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check appointment access permission
|
||||
*
|
||||
* @param int|WP_User $user User ID or user object
|
||||
* @param int $appointment_id Appointment ID
|
||||
* @return bool True if user can access appointment
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function can_access_appointment( $user, $appointment_id ) {
|
||||
if ( is_numeric( $user ) ) {
|
||||
$user = get_user_by( 'id', $user );
|
||||
}
|
||||
|
||||
if ( ! $user instanceof \WP_User ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get appointment data
|
||||
global $wpdb;
|
||||
$appointment = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT doctor_id, patient_id, clinic_id FROM {$wpdb->prefix}kc_appointments WHERE id = %d",
|
||||
$appointment_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! $appointment ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Administrator has access to all appointments
|
||||
if ( in_array( 'administrator', $user->roles ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$primary_role = self::get_primary_kivicare_role( $user );
|
||||
|
||||
switch ( $primary_role ) {
|
||||
case 'kivicare_doctor':
|
||||
// Doctors can access their own appointments or appointments in their clinics
|
||||
return $user->ID === (int) $appointment['doctor_id'] ||
|
||||
self::doctor_has_clinic_access( $user->ID, $appointment['clinic_id'] );
|
||||
|
||||
case 'kivicare_patient':
|
||||
// Patients can only access their own appointments
|
||||
return $user->ID === (int) $appointment['patient_id'];
|
||||
|
||||
case 'kivicare_receptionist':
|
||||
// Receptionists can access appointments in their clinics
|
||||
return self::receptionist_has_clinic_access( $user->ID, $appointment['clinic_id'] );
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered clinic IDs for user
|
||||
*
|
||||
* @param int|WP_User $user User ID or user object
|
||||
* @return array Array of clinic IDs user can access
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_accessible_clinic_ids( $user ) {
|
||||
if ( is_numeric( $user ) ) {
|
||||
$user = get_user_by( 'id', $user );
|
||||
}
|
||||
|
||||
if ( ! $user instanceof \WP_User ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
// Administrator has access to all clinics
|
||||
if ( in_array( 'administrator', $user->roles ) ) {
|
||||
global $wpdb;
|
||||
$clinic_ids = $wpdb->get_col( "SELECT id FROM {$wpdb->prefix}kc_clinics WHERE status = 1" );
|
||||
return array_map( 'intval', $clinic_ids );
|
||||
}
|
||||
|
||||
$primary_role = self::get_primary_kivicare_role( $user );
|
||||
|
||||
switch ( $primary_role ) {
|
||||
case 'kivicare_doctor':
|
||||
return self::get_doctor_clinic_ids( $user->ID );
|
||||
|
||||
case 'kivicare_patient':
|
||||
return self::get_patient_clinic_ids( $user->ID );
|
||||
|
||||
case 'kivicare_receptionist':
|
||||
return self::get_receptionist_clinic_ids( $user->ID );
|
||||
|
||||
default:
|
||||
return array();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Define permission matrix for each role
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function define_permission_matrix() {
|
||||
self::$permission_matrix = array(
|
||||
'administrator' => array(
|
||||
// Full system access
|
||||
'manage_clinics',
|
||||
'manage_users',
|
||||
'manage_doctors',
|
||||
'manage_patients',
|
||||
'manage_appointments',
|
||||
'manage_encounters',
|
||||
'manage_prescriptions',
|
||||
'manage_bills',
|
||||
'manage_services',
|
||||
'view_all_data',
|
||||
'manage_settings',
|
||||
'view_reports',
|
||||
'export_data'
|
||||
),
|
||||
|
||||
'kivicare_doctor' => array(
|
||||
// Patient management within assigned clinics
|
||||
'view_patients',
|
||||
'create_patients',
|
||||
'edit_assigned_patients',
|
||||
|
||||
// Appointment management
|
||||
'view_own_appointments',
|
||||
'edit_own_appointments',
|
||||
'create_appointments',
|
||||
|
||||
// Medical records
|
||||
'view_patient_encounters',
|
||||
'create_encounters',
|
||||
'edit_own_encounters',
|
||||
'view_prescriptions',
|
||||
'create_prescriptions',
|
||||
'edit_own_prescriptions',
|
||||
|
||||
// Billing (limited)
|
||||
'view_bills',
|
||||
'create_bills',
|
||||
|
||||
// Services
|
||||
'view_services',
|
||||
|
||||
// Profile management
|
||||
'edit_own_profile',
|
||||
'view_own_schedule'
|
||||
),
|
||||
|
||||
'kivicare_patient' => array(
|
||||
// Own data access only
|
||||
'view_own_profile',
|
||||
'edit_own_profile',
|
||||
'view_own_appointments',
|
||||
'create_own_appointments', // If enabled
|
||||
'view_own_encounters',
|
||||
'view_own_prescriptions',
|
||||
'view_own_bills',
|
||||
'view_services'
|
||||
),
|
||||
|
||||
'kivicare_receptionist' => array(
|
||||
// Patient management for assigned clinics
|
||||
'view_patients',
|
||||
'create_patients',
|
||||
'edit_patients',
|
||||
|
||||
// Appointment management
|
||||
'view_appointments',
|
||||
'create_appointments',
|
||||
'edit_appointments',
|
||||
'cancel_appointments',
|
||||
|
||||
// Basic billing
|
||||
'view_bills',
|
||||
'create_bills',
|
||||
'process_payments',
|
||||
|
||||
// Services
|
||||
'view_services',
|
||||
|
||||
// Limited reporting
|
||||
'view_basic_reports'
|
||||
)
|
||||
);
|
||||
|
||||
// Apply filters to allow customization
|
||||
self::$permission_matrix = apply_filters( 'kivicare_permission_matrix', self::$permission_matrix );
|
||||
}
|
||||
|
||||
/**
|
||||
* Define resource-specific permissions
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function define_resource_permissions() {
|
||||
self::$resource_permissions = array(
|
||||
'clinics' => array(
|
||||
'view' => array( 'administrator', 'kivicare_doctor', 'kivicare_receptionist' ),
|
||||
'create' => array( 'administrator' ),
|
||||
'edit' => array( 'administrator' ),
|
||||
'delete' => array( 'administrator' )
|
||||
),
|
||||
|
||||
'patients' => array(
|
||||
'view' => array( 'administrator', 'kivicare_doctor', 'kivicare_receptionist' ),
|
||||
'create' => array( 'administrator', 'kivicare_doctor', 'kivicare_receptionist' ),
|
||||
'edit' => array( 'administrator', 'kivicare_doctor', 'kivicare_receptionist' ),
|
||||
'delete' => array( 'administrator' )
|
||||
),
|
||||
|
||||
'appointments' => array(
|
||||
'view' => array( 'administrator', 'kivicare_doctor', 'kivicare_patient', 'kivicare_receptionist' ),
|
||||
'create' => array( 'administrator', 'kivicare_doctor', 'kivicare_receptionist' ),
|
||||
'edit' => array( 'administrator', 'kivicare_doctor', 'kivicare_receptionist' ),
|
||||
'delete' => array( 'administrator', 'kivicare_doctor', 'kivicare_receptionist' )
|
||||
),
|
||||
|
||||
'encounters' => array(
|
||||
'view' => array( 'administrator', 'kivicare_doctor' ),
|
||||
'create' => array( 'administrator', 'kivicare_doctor' ),
|
||||
'edit' => array( 'administrator', 'kivicare_doctor' ),
|
||||
'delete' => array( 'administrator' )
|
||||
),
|
||||
|
||||
'prescriptions' => array(
|
||||
'view' => array( 'administrator', 'kivicare_doctor', 'kivicare_patient' ),
|
||||
'create' => array( 'administrator', 'kivicare_doctor' ),
|
||||
'edit' => array( 'administrator', 'kivicare_doctor' ),
|
||||
'delete' => array( 'administrator', 'kivicare_doctor' )
|
||||
),
|
||||
|
||||
'bills' => array(
|
||||
'view' => array( 'administrator', 'kivicare_doctor', 'kivicare_patient', 'kivicare_receptionist' ),
|
||||
'create' => array( 'administrator', 'kivicare_doctor', 'kivicare_receptionist' ),
|
||||
'edit' => array( 'administrator', 'kivicare_receptionist' ),
|
||||
'delete' => array( 'administrator' )
|
||||
)
|
||||
);
|
||||
|
||||
self::$resource_permissions = apply_filters( 'kivicare_resource_permissions', self::$resource_permissions );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if role has basic permission
|
||||
*
|
||||
* @param string $role User role
|
||||
* @param string $permission Permission to check
|
||||
* @return bool True if role has permission
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function role_has_permission( $role, $permission ) {
|
||||
if ( ! isset( self::$permission_matrix[ $role ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array( $permission, self::$permission_matrix[ $role ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check contextual permissions based on resource ownership and clinic access
|
||||
*
|
||||
* @param WP_User $user User object
|
||||
* @param string $permission Permission to check
|
||||
* @param array $context Context data
|
||||
* @return bool True if user has contextual permission
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function check_contextual_permissions( $user, $permission, $context ) {
|
||||
// Extract context information
|
||||
$resource_id = $context['resource_id'] ?? null;
|
||||
$clinic_id = $context['clinic_id'] ?? null;
|
||||
$patient_id = $context['patient_id'] ?? null;
|
||||
$doctor_id = $context['doctor_id'] ?? null;
|
||||
$resource_type = $context['resource_type'] ?? '';
|
||||
|
||||
$primary_role = self::get_primary_kivicare_role( $user );
|
||||
|
||||
// Apply role-specific contextual rules
|
||||
switch ( $primary_role ) {
|
||||
case 'kivicare_doctor':
|
||||
return self::check_doctor_context( $user->ID, $permission, $context );
|
||||
|
||||
case 'kivicare_patient':
|
||||
return self::check_patient_context( $user->ID, $permission, $context );
|
||||
|
||||
case 'kivicare_receptionist':
|
||||
return self::check_receptionist_context( $user->ID, $permission, $context );
|
||||
|
||||
default:
|
||||
return true; // Administrator or unknown role
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check doctor-specific contextual permissions
|
||||
*
|
||||
* @param int $doctor_id Doctor ID
|
||||
* @param string $permission Permission to check
|
||||
* @param array $context Context data
|
||||
* @return bool True if doctor has contextual permission
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function check_doctor_context( $doctor_id, $permission, $context ) {
|
||||
$clinic_id = $context['clinic_id'] ?? null;
|
||||
$patient_id = $context['patient_id'] ?? null;
|
||||
$resource_type = $context['resource_type'] ?? '';
|
||||
|
||||
// Check clinic access
|
||||
if ( $clinic_id && ! self::doctor_has_clinic_access( $doctor_id, $clinic_id ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check patient access
|
||||
if ( $patient_id && ! self::doctor_can_access_patient( $doctor_id, $patient_id ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resource-specific rules
|
||||
if ( $resource_type === 'appointment' ) {
|
||||
$appointment_id = $context['resource_id'] ?? null;
|
||||
if ( $appointment_id ) {
|
||||
return self::can_access_appointment( $doctor_id, $appointment_id );
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check patient-specific contextual permissions
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @param string $permission Permission to check
|
||||
* @param array $context Context data
|
||||
* @return bool True if patient has contextual permission
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function check_patient_context( $patient_id, $permission, $context ) {
|
||||
// Patients can only access their own data
|
||||
$resource_patient_id = $context['patient_id'] ?? null;
|
||||
|
||||
if ( $resource_patient_id && $resource_patient_id !== $patient_id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check receptionist-specific contextual permissions
|
||||
*
|
||||
* @param int $receptionist_id Receptionist ID
|
||||
* @param string $permission Permission to check
|
||||
* @param array $context Context data
|
||||
* @return bool True if receptionist has contextual permission
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function check_receptionist_context( $receptionist_id, $permission, $context ) {
|
||||
$clinic_id = $context['clinic_id'] ?? null;
|
||||
|
||||
// Check clinic access
|
||||
if ( $clinic_id && ! self::receptionist_has_clinic_access( $receptionist_id, $clinic_id ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get primary KiviCare role for user
|
||||
*
|
||||
* @param WP_User $user User object
|
||||
* @return string|null Primary KiviCare role
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_primary_kivicare_role( $user ) {
|
||||
$kivicare_roles = array( 'administrator', 'kivicare_doctor', 'kivicare_patient', 'kivicare_receptionist' );
|
||||
$user_roles = array_intersect( $user->roles, $kivicare_roles );
|
||||
|
||||
if ( empty( $user_roles ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Priority order
|
||||
$priority_order = array( 'administrator', 'kivicare_doctor', 'kivicare_receptionist', 'kivicare_patient' );
|
||||
|
||||
foreach ( $priority_order as $role ) {
|
||||
if ( in_array( $role, $user_roles ) ) {
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
|
||||
return reset( $user_roles );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if doctor has access to specific clinic
|
||||
*
|
||||
* @param int $doctor_id Doctor ID
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return bool True if doctor has access
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function doctor_has_clinic_access( $doctor_id, $clinic_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_doctor_clinic_mappings WHERE doctor_id = %d AND clinic_id = %d",
|
||||
$doctor_id, $clinic_id
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if patient has access to specific clinic
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return bool True if patient has access
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function patient_has_clinic_access( $patient_id, $clinic_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_patient_clinic_mappings WHERE patient_id = %d AND clinic_id = %d",
|
||||
$patient_id, $clinic_id
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if receptionist has access to specific clinic
|
||||
*
|
||||
* @param int $receptionist_id Receptionist ID
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return bool True if receptionist has access
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function receptionist_has_clinic_access( $receptionist_id, $clinic_id ) {
|
||||
// For now, assuming receptionists are assigned via user meta
|
||||
// This could be extended with a dedicated mapping table
|
||||
$assigned_clinics = get_user_meta( $receptionist_id, 'kivicare_assigned_clinics', true );
|
||||
|
||||
if ( ! is_array( $assigned_clinics ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array( $clinic_id, $assigned_clinics );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if doctor can access specific patient
|
||||
*
|
||||
* @param int $doctor_id Doctor ID
|
||||
* @param int $patient_id Patient ID
|
||||
* @return bool True if doctor can access patient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function doctor_can_access_patient( $doctor_id, $patient_id ) {
|
||||
global $wpdb;
|
||||
|
||||
// Check if doctor has appointments with this patient
|
||||
$count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments WHERE doctor_id = %d AND patient_id = %d",
|
||||
$doctor_id, $patient_id
|
||||
)
|
||||
);
|
||||
|
||||
if ( (int) $count > 0 ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if patient is in doctor's clinics
|
||||
$doctor_clinics = self::get_doctor_clinic_ids( $doctor_id );
|
||||
$patient_clinics = self::get_patient_clinic_ids( $patient_id );
|
||||
|
||||
return ! empty( array_intersect( $doctor_clinics, $patient_clinics ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if receptionist can access specific patient
|
||||
*
|
||||
* @param int $receptionist_id Receptionist ID
|
||||
* @param int $patient_id Patient ID
|
||||
* @return bool True if receptionist can access patient
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function receptionist_can_access_patient( $receptionist_id, $patient_id ) {
|
||||
$receptionist_clinics = self::get_receptionist_clinic_ids( $receptionist_id );
|
||||
$patient_clinics = self::get_patient_clinic_ids( $patient_id );
|
||||
|
||||
return ! empty( array_intersect( $receptionist_clinics, $patient_clinics ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clinic IDs for doctor
|
||||
*
|
||||
* @param int $doctor_id Doctor ID
|
||||
* @return array Array of clinic IDs
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_doctor_clinic_ids( $doctor_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$clinic_ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT clinic_id FROM {$wpdb->prefix}kc_doctor_clinic_mappings WHERE doctor_id = %d",
|
||||
$doctor_id
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( 'intval', $clinic_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clinic IDs for patient
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @return array Array of clinic IDs
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_patient_clinic_ids( $patient_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$clinic_ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT clinic_id FROM {$wpdb->prefix}kc_patient_clinic_mappings WHERE patient_id = %d",
|
||||
$patient_id
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( 'intval', $clinic_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clinic IDs for receptionist
|
||||
*
|
||||
* @param int $receptionist_id Receptionist ID
|
||||
* @return array Array of clinic IDs
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_receptionist_clinic_ids( $receptionist_id ) {
|
||||
$assigned_clinics = get_user_meta( $receptionist_id, 'kivicare_assigned_clinics', true );
|
||||
|
||||
if ( ! is_array( $assigned_clinics ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
return array_map( 'intval', $assigned_clinics );
|
||||
}
|
||||
|
||||
/**
|
||||
* WordPress hook: Modify user capabilities
|
||||
*
|
||||
* @param array $allcaps All capabilities
|
||||
* @param array $caps Requested capabilities
|
||||
* @param array $args Arguments
|
||||
* @param WP_User $user User object
|
||||
* @return array Modified capabilities
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function user_has_cap( $allcaps, $caps, $args, $user ) {
|
||||
// Only modify for KiviCare capabilities
|
||||
foreach ( $caps as $cap ) {
|
||||
if ( strpos( $cap, 'kivicare_' ) === 0 ) {
|
||||
$allcaps[ $cap ] = self::has_permission( $user, $cap, $args );
|
||||
}
|
||||
}
|
||||
|
||||
return $allcaps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add KiviCare-specific capabilities to WordPress
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function add_kivicare_capabilities() {
|
||||
// Get all unique capabilities from permission matrix
|
||||
$all_capabilities = array();
|
||||
|
||||
foreach ( self::$permission_matrix as $role_caps ) {
|
||||
$all_capabilities = array_merge( $all_capabilities, $role_caps );
|
||||
}
|
||||
|
||||
$all_capabilities = array_unique( $all_capabilities );
|
||||
|
||||
// Add capabilities to administrator role
|
||||
$admin_role = get_role( 'administrator' );
|
||||
if ( $admin_role ) {
|
||||
foreach ( $all_capabilities as $cap ) {
|
||||
$admin_role->add_cap( $cap );
|
||||
}
|
||||
}
|
||||
|
||||
// Add role-specific capabilities
|
||||
foreach ( self::$permission_matrix as $role_name => $capabilities ) {
|
||||
$role = get_role( $role_name );
|
||||
if ( $role ) {
|
||||
foreach ( $capabilities as $cap ) {
|
||||
$role->add_cap( $cap );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
905
src/includes/services/class-session-service.php
Normal file
905
src/includes/services/class-session-service.php
Normal file
@@ -0,0 +1,905 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Session Service
|
||||
*
|
||||
* Handles user session management, security and monitoring
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Services
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Services;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Session_Service
|
||||
*
|
||||
* Session management and security monitoring for KiviCare API
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Session_Service {
|
||||
|
||||
/**
|
||||
* Maximum concurrent sessions per user
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private static $max_concurrent_sessions = 3;
|
||||
|
||||
/**
|
||||
* Session timeout (in seconds) - 30 minutes
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private static $session_timeout = 1800;
|
||||
|
||||
/**
|
||||
* Maximum failed login attempts
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private static $max_failed_attempts = 5;
|
||||
|
||||
/**
|
||||
* Lockout duration (in seconds) - 15 minutes
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private static $lockout_duration = 900;
|
||||
|
||||
/**
|
||||
* Initialize the session service
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function init() {
|
||||
// Hook into authentication events
|
||||
add_action( 'kivicare_user_authenticated', array( self::class, 'on_user_authenticated' ), 10, 2 );
|
||||
add_action( 'kivicare_user_logout', array( self::class, 'on_user_logout' ), 10, 1 );
|
||||
add_action( 'kivicare_failed_login', array( self::class, 'on_failed_login' ), 10, 1 );
|
||||
|
||||
// Cleanup expired sessions
|
||||
add_action( 'kivicare_cleanup_sessions', array( self::class, 'cleanup_expired_sessions' ) );
|
||||
|
||||
// Schedule cleanup if not already scheduled
|
||||
if ( ! wp_next_scheduled( 'kivicare_cleanup_sessions' ) ) {
|
||||
wp_schedule_event( time(), 'hourly', 'kivicare_cleanup_sessions' );
|
||||
}
|
||||
|
||||
// Monitor session activity
|
||||
add_action( 'init', array( self::class, 'monitor_session_activity' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new session for user
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @param string $ip_address IP address
|
||||
* @param string $user_agent User agent
|
||||
* @return string Session ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function create_session( $user_id, $ip_address, $user_agent ) {
|
||||
// Generate unique session ID
|
||||
$session_id = wp_generate_uuid4();
|
||||
|
||||
// Check concurrent session limit
|
||||
self::enforce_concurrent_session_limit( $user_id );
|
||||
|
||||
// Create session data
|
||||
$session_data = array(
|
||||
'session_id' => $session_id,
|
||||
'user_id' => $user_id,
|
||||
'ip_address' => $ip_address,
|
||||
'user_agent' => $user_agent,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
'last_activity' => current_time( 'mysql' ),
|
||||
'expires_at' => date( 'Y-m-d H:i:s', time() + self::$session_timeout ),
|
||||
'is_active' => 1
|
||||
);
|
||||
|
||||
// Store session in database
|
||||
self::store_session( $session_data );
|
||||
|
||||
// Update user session metadata
|
||||
self::update_user_session_meta( $user_id, $session_id, $ip_address );
|
||||
|
||||
return $session_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session
|
||||
*
|
||||
* @param string $session_id Session ID
|
||||
* @param int $user_id User ID
|
||||
* @return bool|array Session data or false if invalid
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function validate_session( $session_id, $user_id ) {
|
||||
$session = self::get_session( $session_id );
|
||||
|
||||
if ( ! $session ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if session belongs to user
|
||||
if ( (int) $session['user_id'] !== $user_id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if session is active
|
||||
if ( ! $session['is_active'] ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if session is expired
|
||||
if ( strtotime( $session['expires_at'] ) < time() ) {
|
||||
self::expire_session( $session_id );
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for session timeout based on last activity
|
||||
$last_activity = strtotime( $session['last_activity'] );
|
||||
if ( ( time() - $last_activity ) > self::$session_timeout ) {
|
||||
self::expire_session( $session_id );
|
||||
return false;
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session activity
|
||||
*
|
||||
* @param string $session_id Session ID
|
||||
* @return bool Success status
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function update_session_activity( $session_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$result = $wpdb->update(
|
||||
$wpdb->prefix . 'kivicare_sessions',
|
||||
array(
|
||||
'last_activity' => current_time( 'mysql' ),
|
||||
'expires_at' => date( 'Y-m-d H:i:s', time() + self::$session_timeout )
|
||||
),
|
||||
array( 'session_id' => $session_id ),
|
||||
array( '%s', '%s' ),
|
||||
array( '%s' )
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire session
|
||||
*
|
||||
* @param string $session_id Session ID
|
||||
* @return bool Success status
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function expire_session( $session_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$result = $wpdb->update(
|
||||
$wpdb->prefix . 'kivicare_sessions',
|
||||
array(
|
||||
'is_active' => 0,
|
||||
'ended_at' => current_time( 'mysql' )
|
||||
),
|
||||
array( 'session_id' => $session_id ),
|
||||
array( '%d', '%s' ),
|
||||
array( '%s' )
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire all sessions for user
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @return bool Success status
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function expire_user_sessions( $user_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$result = $wpdb->update(
|
||||
$wpdb->prefix . 'kivicare_sessions',
|
||||
array(
|
||||
'is_active' => 0,
|
||||
'ended_at' => current_time( 'mysql' )
|
||||
),
|
||||
array(
|
||||
'user_id' => $user_id,
|
||||
'is_active' => 1
|
||||
),
|
||||
array( '%d', '%s' ),
|
||||
array( '%d', '%d' )
|
||||
);
|
||||
|
||||
// Clear user session metadata
|
||||
delete_user_meta( $user_id, 'kivicare_current_session' );
|
||||
delete_user_meta( $user_id, 'kivicare_session_ip' );
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active sessions for user
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @return array Array of active sessions
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_user_sessions( $user_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$sessions = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kivicare_sessions
|
||||
WHERE user_id = %d AND is_active = 1 AND expires_at > NOW()
|
||||
ORDER BY last_activity DESC",
|
||||
$user_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map( array( self::class, 'format_session_data' ), $sessions );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user account is locked
|
||||
*
|
||||
* @param int|string $user_identifier User ID or username
|
||||
* @return bool True if account is locked
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function is_account_locked( $user_identifier ) {
|
||||
if ( is_numeric( $user_identifier ) ) {
|
||||
$user = get_user_by( 'id', $user_identifier );
|
||||
} else {
|
||||
$user = get_user_by( 'login', $user_identifier );
|
||||
if ( ! $user && is_email( $user_identifier ) ) {
|
||||
$user = get_user_by( 'email', $user_identifier );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $user ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lockout_time = get_user_meta( $user->ID, 'kivicare_lockout_time', true );
|
||||
|
||||
if ( ! $lockout_time ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if lockout has expired
|
||||
if ( time() > $lockout_time ) {
|
||||
delete_user_meta( $user->ID, 'kivicare_lockout_time' );
|
||||
delete_user_meta( $user->ID, 'kivicare_failed_attempts' );
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining lockout time
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @return int Remaining lockout time in seconds
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_lockout_remaining_time( $user_id ) {
|
||||
$lockout_time = get_user_meta( $user_id, 'kivicare_lockout_time', true );
|
||||
|
||||
if ( ! $lockout_time ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$remaining = $lockout_time - time();
|
||||
return max( 0, $remaining );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session statistics for user
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @return array Session statistics
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_user_session_stats( $user_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$stats = array(
|
||||
'active_sessions' => 0,
|
||||
'total_sessions_today' => 0,
|
||||
'total_sessions_this_month' => 0,
|
||||
'last_login' => null,
|
||||
'last_ip' => null,
|
||||
'failed_attempts_today' => 0,
|
||||
'is_locked' => false,
|
||||
'lockout_remaining' => 0
|
||||
);
|
||||
|
||||
// Active sessions
|
||||
$stats['active_sessions'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kivicare_sessions
|
||||
WHERE user_id = %d AND is_active = 1 AND expires_at > NOW()",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
// Sessions today
|
||||
$stats['total_sessions_today'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kivicare_sessions
|
||||
WHERE user_id = %d AND DATE(created_at) = CURDATE()",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
// Sessions this month
|
||||
$stats['total_sessions_this_month'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kivicare_sessions
|
||||
WHERE user_id = %d AND MONTH(created_at) = MONTH(CURDATE()) AND YEAR(created_at) = YEAR(CURDATE())",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
// Last login info
|
||||
$last_session = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT created_at, ip_address FROM {$wpdb->prefix}kivicare_sessions
|
||||
WHERE user_id = %d ORDER BY created_at DESC LIMIT 1",
|
||||
$user_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( $last_session ) {
|
||||
$stats['last_login'] = $last_session['created_at'];
|
||||
$stats['last_ip'] = $last_session['ip_address'];
|
||||
}
|
||||
|
||||
// Failed attempts today
|
||||
$stats['failed_attempts_today'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kivicare_failed_logins
|
||||
WHERE user_id = %d AND DATE(attempted_at) = CURDATE()",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
// Lockout status
|
||||
$stats['is_locked'] = self::is_account_locked( $user_id );
|
||||
$stats['lockout_remaining'] = self::get_lockout_remaining_time( $user_id );
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor session activity for security
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function monitor_session_activity() {
|
||||
// Only monitor for authenticated API requests
|
||||
if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
if ( ! $user_id ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current session from auth header or meta
|
||||
$session_id = self::get_current_session_id( $user_id );
|
||||
|
||||
if ( $session_id ) {
|
||||
// Update session activity
|
||||
self::update_session_activity( $session_id );
|
||||
|
||||
// Check for suspicious activity
|
||||
self::detect_suspicious_activity( $user_id, $session_id );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session ID for user
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @return string|null Session ID or null
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_current_session_id( $user_id ) {
|
||||
// Try to get from JWT token first (would need to be added to JWT payload)
|
||||
// For now, get from user meta (set during authentication)
|
||||
return get_user_meta( $user_id, 'kivicare_current_session', true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect suspicious session activity
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @param string $session_id Session ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function detect_suspicious_activity( $user_id, $session_id ) {
|
||||
$session = self::get_session( $session_id );
|
||||
if ( ! $session ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$current_ip = self::get_client_ip();
|
||||
$session_ip = $session['ip_address'];
|
||||
|
||||
// Check for IP address change
|
||||
if ( $current_ip !== $session_ip ) {
|
||||
self::log_security_event( $user_id, 'ip_change', array(
|
||||
'session_id' => $session_id,
|
||||
'original_ip' => $session_ip,
|
||||
'new_ip' => $current_ip
|
||||
) );
|
||||
|
||||
// Optionally expire session or require re-authentication
|
||||
if ( apply_filters( 'kivicare_expire_on_ip_change', false ) ) {
|
||||
self::expire_session( $session_id );
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unusual activity patterns
|
||||
self::check_activity_patterns( $user_id, $session_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for unusual activity patterns
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @param string $session_id Session ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function check_activity_patterns( $user_id, $session_id ) {
|
||||
// This could be extended to check:
|
||||
// - Rapid API calls (possible bot activity)
|
||||
// - Access to unusual resources
|
||||
// - Concurrent sessions from different locations
|
||||
// - Time-based anomalies (access at unusual hours)
|
||||
|
||||
do_action( 'kivicare_check_activity_patterns', $user_id, $session_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user authentication event
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @param array $context Authentication context
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function on_user_authenticated( $user_id, $context ) {
|
||||
$ip_address = $context['ip_address'] ?? self::get_client_ip();
|
||||
$user_agent = $context['user_agent'] ?? ( $_SERVER['HTTP_USER_AGENT'] ?? '' );
|
||||
|
||||
// Create new session
|
||||
$session_id = self::create_session( $user_id, $ip_address, $user_agent );
|
||||
|
||||
// Log successful authentication
|
||||
self::log_security_event( $user_id, 'login', array(
|
||||
'session_id' => $session_id,
|
||||
'ip_address' => $ip_address,
|
||||
'user_agent' => $user_agent
|
||||
) );
|
||||
|
||||
// Clear failed login attempts
|
||||
delete_user_meta( $user_id, 'kivicare_failed_attempts' );
|
||||
delete_user_meta( $user_id, 'kivicare_lockout_time' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user logout event
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function on_user_logout( $user_id ) {
|
||||
$session_id = get_user_meta( $user_id, 'kivicare_current_session', true );
|
||||
|
||||
if ( $session_id ) {
|
||||
self::expire_session( $session_id );
|
||||
}
|
||||
|
||||
// Log logout
|
||||
self::log_security_event( $user_id, 'logout', array(
|
||||
'session_id' => $session_id
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle failed login event
|
||||
*
|
||||
* @param array $context Failed login context
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function on_failed_login( $context ) {
|
||||
$user_id = $context['user_id'] ?? null;
|
||||
$username = $context['username'] ?? '';
|
||||
$ip_address = $context['ip_address'] ?? self::get_client_ip();
|
||||
|
||||
// Log failed attempt
|
||||
self::log_failed_login_attempt( $user_id, $username, $ip_address );
|
||||
|
||||
if ( $user_id ) {
|
||||
// Increment failed attempts counter
|
||||
$failed_attempts = (int) get_user_meta( $user_id, 'kivicare_failed_attempts', true ) + 1;
|
||||
update_user_meta( $user_id, 'kivicare_failed_attempts', $failed_attempts );
|
||||
|
||||
// Check if account should be locked
|
||||
if ( $failed_attempts >= self::$max_failed_attempts ) {
|
||||
self::lock_account( $user_id );
|
||||
}
|
||||
}
|
||||
|
||||
// Log security event
|
||||
self::log_security_event( $user_id, 'failed_login', $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock user account
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function lock_account( $user_id ) {
|
||||
$lockout_time = time() + self::$lockout_duration;
|
||||
update_user_meta( $user_id, 'kivicare_lockout_time', $lockout_time );
|
||||
|
||||
// Expire all active sessions
|
||||
self::expire_user_sessions( $user_id );
|
||||
|
||||
// Log security event
|
||||
self::log_security_event( $user_id, 'account_locked', array(
|
||||
'lockout_duration' => self::$lockout_duration,
|
||||
'lockout_until' => date( 'Y-m-d H:i:s', $lockout_time )
|
||||
) );
|
||||
|
||||
// Send notification (could be extended)
|
||||
do_action( 'kivicare_account_locked', $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce concurrent session limit
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function enforce_concurrent_session_limit( $user_id ) {
|
||||
global $wpdb;
|
||||
|
||||
// Get active sessions count
|
||||
$active_sessions = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kivicare_sessions
|
||||
WHERE user_id = %d AND is_active = 1 AND expires_at > NOW()",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
// If at limit, expire oldest session
|
||||
if ( $active_sessions >= self::$max_concurrent_sessions ) {
|
||||
$oldest_session = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT session_id FROM {$wpdb->prefix}kivicare_sessions
|
||||
WHERE user_id = %d AND is_active = 1 AND expires_at > NOW()
|
||||
ORDER BY last_activity ASC LIMIT 1",
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
if ( $oldest_session ) {
|
||||
self::expire_session( $oldest_session );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store session in database
|
||||
*
|
||||
* @param array $session_data Session data
|
||||
* @return bool Success status
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function store_session( $session_data ) {
|
||||
global $wpdb;
|
||||
|
||||
// Create table if it doesn't exist
|
||||
self::create_sessions_table();
|
||||
|
||||
$result = $wpdb->insert(
|
||||
$wpdb->prefix . 'kivicare_sessions',
|
||||
$session_data,
|
||||
array( '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d' )
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session from database
|
||||
*
|
||||
* @param string $session_id Session ID
|
||||
* @return array|null Session data or null
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_session( $session_id ) {
|
||||
global $wpdb;
|
||||
|
||||
return $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kivicare_sessions WHERE session_id = %s",
|
||||
$session_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user session metadata
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @param string $session_id Session ID
|
||||
* @param string $ip_address IP address
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function update_user_session_meta( $user_id, $session_id, $ip_address ) {
|
||||
update_user_meta( $user_id, 'kivicare_current_session', $session_id );
|
||||
update_user_meta( $user_id, 'kivicare_session_ip', $ip_address );
|
||||
update_user_meta( $user_id, 'kivicare_last_activity', current_time( 'mysql' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Log failed login attempt
|
||||
*
|
||||
* @param int|null $user_id User ID (if found)
|
||||
* @param string $username Username attempted
|
||||
* @param string $ip_address IP address
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function log_failed_login_attempt( $user_id, $username, $ip_address ) {
|
||||
global $wpdb;
|
||||
|
||||
// Create table if it doesn't exist
|
||||
self::create_failed_logins_table();
|
||||
|
||||
$wpdb->insert(
|
||||
$wpdb->prefix . 'kivicare_failed_logins',
|
||||
array(
|
||||
'user_id' => $user_id,
|
||||
'username' => $username,
|
||||
'ip_address' => $ip_address,
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
'attempted_at' => current_time( 'mysql' )
|
||||
),
|
||||
array( '%d', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security event
|
||||
*
|
||||
* @param int $user_id User ID
|
||||
* @param string $event Event type
|
||||
* @param array $data Event data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function log_security_event( $user_id, $event, $data = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
// Create table if it doesn't exist
|
||||
self::create_security_log_table();
|
||||
|
||||
$wpdb->insert(
|
||||
$wpdb->prefix . 'kivicare_security_log',
|
||||
array(
|
||||
'user_id' => $user_id,
|
||||
'event_type' => $event,
|
||||
'event_data' => wp_json_encode( $data ),
|
||||
'ip_address' => self::get_client_ip(),
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
'created_at' => current_time( 'mysql' )
|
||||
),
|
||||
array( '%d', '%s', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format session data for API response
|
||||
*
|
||||
* @param array $session_data Raw session data
|
||||
* @return array Formatted session data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function format_session_data( $session_data ) {
|
||||
return array(
|
||||
'session_id' => $session_data['session_id'],
|
||||
'ip_address' => $session_data['ip_address'],
|
||||
'user_agent' => $session_data['user_agent'],
|
||||
'created_at' => $session_data['created_at'],
|
||||
'last_activity' => $session_data['last_activity'],
|
||||
'expires_at' => $session_data['expires_at'],
|
||||
'is_current' => get_user_meta( $session_data['user_id'], 'kivicare_current_session', true ) === $session_data['session_id']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired sessions
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function cleanup_expired_sessions() {
|
||||
global $wpdb;
|
||||
|
||||
// Delete expired sessions
|
||||
$wpdb->query(
|
||||
"DELETE FROM {$wpdb->prefix}kivicare_sessions
|
||||
WHERE expires_at < NOW() OR (is_active = 0 AND ended_at < DATE_SUB(NOW(), INTERVAL 30 DAY))"
|
||||
);
|
||||
|
||||
// Clean up old failed login attempts
|
||||
$wpdb->query(
|
||||
"DELETE FROM {$wpdb->prefix}kivicare_failed_logins
|
||||
WHERE attempted_at < DATE_SUB(NOW(), INTERVAL 7 DAY)"
|
||||
);
|
||||
|
||||
// Clean up old security log entries (keep 90 days)
|
||||
$wpdb->query(
|
||||
"DELETE FROM {$wpdb->prefix}kivicare_security_log
|
||||
WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY)"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address
|
||||
*
|
||||
* @return string IP address
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_client_ip() {
|
||||
$ip_keys = array(
|
||||
'HTTP_CF_CONNECTING_IP',
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_FORWARDED',
|
||||
'HTTP_X_CLUSTER_CLIENT_IP',
|
||||
'HTTP_FORWARDED_FOR',
|
||||
'HTTP_FORWARDED',
|
||||
'REMOTE_ADDR'
|
||||
);
|
||||
|
||||
foreach ( $ip_keys as $key ) {
|
||||
if ( ! empty( $_SERVER[ $key ] ) ) {
|
||||
$ip = $_SERVER[ $key ];
|
||||
if ( strpos( $ip, ',' ) !== false ) {
|
||||
$ip = explode( ',', $ip )[0];
|
||||
}
|
||||
$ip = trim( $ip );
|
||||
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sessions table
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function create_sessions_table() {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'kivicare_sessions';
|
||||
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE IF NOT EXISTS {$table_name} (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
session_id varchar(36) NOT NULL,
|
||||
user_id bigint(20) unsigned NOT NULL,
|
||||
ip_address varchar(45) NOT NULL,
|
||||
user_agent text NOT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
last_activity datetime NOT NULL,
|
||||
expires_at datetime NOT NULL,
|
||||
ended_at datetime NULL,
|
||||
is_active tinyint(1) NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY session_id (session_id),
|
||||
KEY user_id (user_id),
|
||||
KEY expires_at (expires_at),
|
||||
KEY is_active (is_active)
|
||||
) {$charset_collate};";
|
||||
|
||||
require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
|
||||
dbDelta( $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create failed logins table
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function create_failed_logins_table() {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'kivicare_failed_logins';
|
||||
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE IF NOT EXISTS {$table_name} (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
user_id bigint(20) unsigned NULL,
|
||||
username varchar(60) NOT NULL,
|
||||
ip_address varchar(45) NOT NULL,
|
||||
user_agent text NOT NULL,
|
||||
attempted_at datetime NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY user_id (user_id),
|
||||
KEY ip_address (ip_address),
|
||||
KEY attempted_at (attempted_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
|
||||
dbDelta( $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create security log table
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function create_security_log_table() {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'kivicare_security_log';
|
||||
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE IF NOT EXISTS {$table_name} (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
user_id bigint(20) unsigned NULL,
|
||||
event_type varchar(50) NOT NULL,
|
||||
event_data longtext NULL,
|
||||
ip_address varchar(45) NOT NULL,
|
||||
user_agent text NOT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY user_id (user_id),
|
||||
KEY event_type (event_type),
|
||||
KEY created_at (created_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
|
||||
dbDelta( $sql );
|
||||
}
|
||||
}
|
||||
966
src/includes/services/database/class-appointment-service.php
Normal file
966
src/includes/services/database/class-appointment-service.php
Normal file
@@ -0,0 +1,966 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Appointment Database Service
|
||||
*
|
||||
* Handles advanced appointment data operations and business logic
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Services\Database
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Services\Database;
|
||||
|
||||
use KiviCare_API\Models\Appointment;
|
||||
use KiviCare_API\Services\Permission_Service;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Appointment_Service
|
||||
*
|
||||
* Advanced database service for appointment management with business logic
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Appointment_Service {
|
||||
|
||||
/**
|
||||
* Appointment status constants
|
||||
*/
|
||||
const STATUS_BOOKED = 1;
|
||||
const STATUS_COMPLETED = 2;
|
||||
const STATUS_CANCELLED = 3;
|
||||
const STATUS_NO_SHOW = 4;
|
||||
const STATUS_RESCHEDULED = 5;
|
||||
|
||||
/**
|
||||
* Initialize the service
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function init() {
|
||||
// Hook into WordPress actions
|
||||
add_action( 'kivicare_appointment_created', array( self::class, 'on_appointment_created' ), 10, 2 );
|
||||
add_action( 'kivicare_appointment_updated', array( self::class, 'on_appointment_updated' ), 10, 2 );
|
||||
add_action( 'kivicare_appointment_cancelled', array( self::class, 'on_appointment_cancelled' ), 10, 1 );
|
||||
add_action( 'kivicare_appointment_completed', array( self::class, 'on_appointment_completed' ), 10, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create appointment with advanced business logic
|
||||
*
|
||||
* @param array $appointment_data Appointment data
|
||||
* @param int $user_id Creating user ID
|
||||
* @return array|WP_Error Appointment data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function create_appointment( $appointment_data, $user_id = null ) {
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_manage_appointments( get_current_user_id(), $appointment_data['clinic_id'] ?? 0 ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'You do not have permission to create appointments',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced validation
|
||||
$validation = self::validate_appointment_business_rules( $appointment_data );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
// Check doctor availability
|
||||
$availability_check = self::check_doctor_availability( $appointment_data );
|
||||
if ( is_wp_error( $availability_check ) ) {
|
||||
return $availability_check;
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
$appointment_data['created_by'] = $user_id ?: get_current_user_id();
|
||||
$appointment_data['created_at'] = current_time( 'mysql' );
|
||||
$appointment_data['status'] = self::STATUS_BOOKED;
|
||||
|
||||
// Generate appointment number if not provided
|
||||
if ( empty( $appointment_data['appointment_number'] ) ) {
|
||||
$appointment_data['appointment_number'] = self::generate_appointment_number();
|
||||
}
|
||||
|
||||
// Calculate end time if not provided
|
||||
if ( empty( $appointment_data['appointment_end_time'] ) ) {
|
||||
$appointment_data['appointment_end_time'] = self::calculate_end_time(
|
||||
$appointment_data['appointment_start_time'],
|
||||
$appointment_data['duration'] ?? 30
|
||||
);
|
||||
}
|
||||
|
||||
// Create appointment
|
||||
$appointment_id = Appointment::create( $appointment_data );
|
||||
|
||||
if ( is_wp_error( $appointment_id ) ) {
|
||||
return $appointment_id;
|
||||
}
|
||||
|
||||
// Post-creation tasks
|
||||
self::setup_appointment_defaults( $appointment_id, $appointment_data );
|
||||
|
||||
// Send notifications
|
||||
self::send_appointment_notifications( $appointment_id, 'created' );
|
||||
|
||||
// Trigger action
|
||||
do_action( 'kivicare_appointment_created', $appointment_id, $appointment_data );
|
||||
|
||||
// Return full appointment data
|
||||
return self::get_appointment_with_metadata( $appointment_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Update appointment with business logic
|
||||
*
|
||||
* @param int $appointment_id Appointment ID
|
||||
* @param array $appointment_data Updated data
|
||||
* @return array|WP_Error Updated appointment data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function update_appointment( $appointment_id, $appointment_data ) {
|
||||
// Get current appointment data
|
||||
$current_appointment = Appointment::get_by_id( $appointment_id );
|
||||
if ( ! $current_appointment ) {
|
||||
return new \WP_Error(
|
||||
'appointment_not_found',
|
||||
'Appointment not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_manage_appointments( get_current_user_id(), $current_appointment['clinic_id'] ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'You do not have permission to update this appointment',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced validation
|
||||
$validation = self::validate_appointment_business_rules( $appointment_data, $appointment_id );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
// Check if this is a rescheduling
|
||||
$is_rescheduling = self::is_appointment_rescheduling( $current_appointment, $appointment_data );
|
||||
if ( $is_rescheduling ) {
|
||||
$availability_check = self::check_doctor_availability( $appointment_data, $appointment_id );
|
||||
if ( is_wp_error( $availability_check ) ) {
|
||||
return $availability_check;
|
||||
}
|
||||
}
|
||||
|
||||
// Add update metadata
|
||||
$appointment_data['updated_by'] = get_current_user_id();
|
||||
$appointment_data['updated_at'] = current_time( 'mysql' );
|
||||
|
||||
// Update appointment
|
||||
$result = Appointment::update( $appointment_id, $appointment_data );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Handle status changes
|
||||
self::handle_status_changes( $appointment_id, $current_appointment, $appointment_data );
|
||||
|
||||
// Send notifications if needed
|
||||
if ( $is_rescheduling ) {
|
||||
self::send_appointment_notifications( $appointment_id, 'rescheduled' );
|
||||
}
|
||||
|
||||
// Trigger action
|
||||
do_action( 'kivicare_appointment_updated', $appointment_id, $appointment_data );
|
||||
|
||||
// Return updated appointment data
|
||||
return self::get_appointment_with_metadata( $appointment_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel appointment
|
||||
*
|
||||
* @param int $appointment_id Appointment ID
|
||||
* @param string $reason Cancellation reason
|
||||
* @return array|WP_Error Updated appointment data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function cancel_appointment( $appointment_id, $reason = '' ) {
|
||||
$appointment = Appointment::get_by_id( $appointment_id );
|
||||
if ( ! $appointment ) {
|
||||
return new \WP_Error(
|
||||
'appointment_not_found',
|
||||
'Appointment not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_manage_appointments( get_current_user_id(), $appointment['clinic_id'] ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'You do not have permission to cancel this appointment',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check if appointment can be cancelled
|
||||
if ( $appointment['status'] == self::STATUS_COMPLETED ) {
|
||||
return new \WP_Error(
|
||||
'cannot_cancel_completed',
|
||||
'Cannot cancel a completed appointment',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
if ( $appointment['status'] == self::STATUS_CANCELLED ) {
|
||||
return new \WP_Error(
|
||||
'already_cancelled',
|
||||
'Appointment is already cancelled',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Update appointment status
|
||||
$update_data = array(
|
||||
'status' => self::STATUS_CANCELLED,
|
||||
'cancellation_reason' => $reason,
|
||||
'cancelled_by' => get_current_user_id(),
|
||||
'cancelled_at' => current_time( 'mysql' ),
|
||||
'updated_at' => current_time( 'mysql' )
|
||||
);
|
||||
|
||||
$result = Appointment::update( $appointment_id, $update_data );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Send cancellation notifications
|
||||
self::send_appointment_notifications( $appointment_id, 'cancelled' );
|
||||
|
||||
// Trigger action
|
||||
do_action( 'kivicare_appointment_cancelled', $appointment_id );
|
||||
|
||||
return self::get_appointment_with_metadata( $appointment_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete appointment
|
||||
*
|
||||
* @param int $appointment_id Appointment ID
|
||||
* @param array $completion_data Completion data
|
||||
* @return array|WP_Error Updated appointment data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function complete_appointment( $appointment_id, $completion_data = array() ) {
|
||||
$appointment = Appointment::get_by_id( $appointment_id );
|
||||
if ( ! $appointment ) {
|
||||
return new \WP_Error(
|
||||
'appointment_not_found',
|
||||
'Appointment not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_manage_appointments( get_current_user_id(), $appointment['clinic_id'] ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'You do not have permission to complete this appointment',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check if appointment can be completed
|
||||
if ( $appointment['status'] == self::STATUS_CANCELLED ) {
|
||||
return new \WP_Error(
|
||||
'cannot_complete_cancelled',
|
||||
'Cannot complete a cancelled appointment',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
if ( $appointment['status'] == self::STATUS_COMPLETED ) {
|
||||
return new \WP_Error(
|
||||
'already_completed',
|
||||
'Appointment is already completed',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Update appointment status
|
||||
$update_data = array_merge( $completion_data, array(
|
||||
'status' => self::STATUS_COMPLETED,
|
||||
'completed_by' => get_current_user_id(),
|
||||
'completed_at' => current_time( 'mysql' ),
|
||||
'updated_at' => current_time( 'mysql' )
|
||||
));
|
||||
|
||||
$result = Appointment::update( $appointment_id, $update_data );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Auto-generate bill if configured
|
||||
if ( get_option( 'kivicare_auto_generate_bills', true ) ) {
|
||||
self::auto_generate_bill( $appointment_id );
|
||||
}
|
||||
|
||||
// Send completion notifications
|
||||
self::send_appointment_notifications( $appointment_id, 'completed' );
|
||||
|
||||
// Trigger action
|
||||
do_action( 'kivicare_appointment_completed', $appointment_id );
|
||||
|
||||
return self::get_appointment_with_metadata( $appointment_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appointment with enhanced metadata
|
||||
*
|
||||
* @param int $appointment_id Appointment ID
|
||||
* @return array|WP_Error Appointment data with metadata or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_appointment_with_metadata( $appointment_id ) {
|
||||
$appointment = Appointment::get_by_id( $appointment_id );
|
||||
|
||||
if ( ! $appointment ) {
|
||||
return new \WP_Error(
|
||||
'appointment_not_found',
|
||||
'Appointment not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_view_appointment( get_current_user_id(), $appointment_id ) ) {
|
||||
return new \WP_Error(
|
||||
'access_denied',
|
||||
'You do not have access to this appointment',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Add enhanced metadata
|
||||
$appointment['patient'] = self::get_appointment_patient( $appointment['patient_id'] );
|
||||
$appointment['doctor'] = self::get_appointment_doctor( $appointment['doctor_id'] );
|
||||
$appointment['clinic'] = self::get_appointment_clinic( $appointment['clinic_id'] );
|
||||
$appointment['service'] = self::get_appointment_service( $appointment['service_id'] ?? null );
|
||||
$appointment['encounters'] = self::get_appointment_encounters( $appointment_id );
|
||||
$appointment['bills'] = self::get_appointment_bills( $appointment_id );
|
||||
$appointment['status_label'] = self::get_status_label( $appointment['status'] );
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search appointments with advanced criteria
|
||||
*
|
||||
* @param array $filters Search filters
|
||||
* @return array Search results
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function search_appointments( $filters = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$accessible_clinic_ids = Permission_Service::get_accessible_clinic_ids( $user_id );
|
||||
|
||||
if ( empty( $accessible_clinic_ids ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
// Build search query
|
||||
$where_clauses = array( "a.clinic_id IN (" . implode( ',', $accessible_clinic_ids ) . ")" );
|
||||
$where_values = array();
|
||||
|
||||
// Date range filter
|
||||
if ( ! empty( $filters['start_date'] ) ) {
|
||||
$where_clauses[] = "DATE(a.appointment_start_date) >= %s";
|
||||
$where_values[] = $filters['start_date'];
|
||||
}
|
||||
|
||||
if ( ! empty( $filters['end_date'] ) ) {
|
||||
$where_clauses[] = "DATE(a.appointment_start_date) <= %s";
|
||||
$where_values[] = $filters['end_date'];
|
||||
}
|
||||
|
||||
// Doctor filter
|
||||
if ( ! empty( $filters['doctor_id'] ) ) {
|
||||
$where_clauses[] = "a.doctor_id = %d";
|
||||
$where_values[] = $filters['doctor_id'];
|
||||
}
|
||||
|
||||
// Patient filter
|
||||
if ( ! empty( $filters['patient_id'] ) ) {
|
||||
$where_clauses[] = "a.patient_id = %d";
|
||||
$where_values[] = $filters['patient_id'];
|
||||
}
|
||||
|
||||
// Clinic filter
|
||||
if ( ! empty( $filters['clinic_id'] ) && in_array( $filters['clinic_id'], $accessible_clinic_ids ) ) {
|
||||
$where_clauses[] = "a.clinic_id = %d";
|
||||
$where_values[] = $filters['clinic_id'];
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ( ! empty( $filters['status'] ) ) {
|
||||
if ( is_array( $filters['status'] ) ) {
|
||||
$status_placeholders = implode( ',', array_fill( 0, count( $filters['status'] ), '%d' ) );
|
||||
$where_clauses[] = "a.status IN ({$status_placeholders})";
|
||||
$where_values = array_merge( $where_values, $filters['status'] );
|
||||
} else {
|
||||
$where_clauses[] = "a.status = %d";
|
||||
$where_values[] = $filters['status'];
|
||||
}
|
||||
}
|
||||
|
||||
// Search term
|
||||
if ( ! empty( $filters['search'] ) ) {
|
||||
$where_clauses[] = "(p.first_name LIKE %s OR p.last_name LIKE %s OR d.first_name LIKE %s OR d.last_name LIKE %s OR a.appointment_number LIKE %s)";
|
||||
$search_term = '%' . $wpdb->esc_like( $filters['search'] ) . '%';
|
||||
$where_values = array_merge( $where_values, array_fill( 0, 5, $search_term ) );
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
// Pagination
|
||||
$limit = isset( $filters['limit'] ) ? (int) $filters['limit'] : 20;
|
||||
$offset = isset( $filters['offset'] ) ? (int) $filters['offset'] : 0;
|
||||
|
||||
$query = "SELECT a.*,
|
||||
p.first_name as patient_first_name, p.last_name as patient_last_name,
|
||||
d.first_name as doctor_first_name, d.last_name as doctor_last_name,
|
||||
c.name as clinic_name
|
||||
FROM {$wpdb->prefix}kc_appointments a
|
||||
LEFT JOIN {$wpdb->prefix}kc_patients p ON a.patient_id = p.id
|
||||
LEFT JOIN {$wpdb->prefix}kc_doctors d ON a.doctor_id = d.id
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON a.clinic_id = c.id
|
||||
WHERE {$where_sql}
|
||||
ORDER BY a.appointment_start_date DESC, a.appointment_start_time DESC
|
||||
LIMIT {$limit} OFFSET {$offset}";
|
||||
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$results = $wpdb->get_results( $wpdb->prepare( $query, $where_values ), ARRAY_A );
|
||||
} else {
|
||||
$results = $wpdb->get_results( $query, ARRAY_A );
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
$count_query = "SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments a
|
||||
LEFT JOIN {$wpdb->prefix}kc_patients p ON a.patient_id = p.id
|
||||
LEFT JOIN {$wpdb->prefix}kc_doctors d ON a.doctor_id = d.id
|
||||
WHERE {$where_sql}";
|
||||
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$total = (int) $wpdb->get_var( $wpdb->prepare( $count_query, $where_values ) );
|
||||
} else {
|
||||
$total = (int) $wpdb->get_var( $count_query );
|
||||
}
|
||||
|
||||
return array(
|
||||
'appointments' => array_map( function( $appointment ) {
|
||||
$appointment['id'] = (int) $appointment['id'];
|
||||
$appointment['status_label'] = self::get_status_label( $appointment['status'] );
|
||||
return $appointment;
|
||||
}, $results ),
|
||||
'total' => $total,
|
||||
'has_more' => ( $offset + $limit ) < $total
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doctor availability for date range
|
||||
*
|
||||
* @param int $doctor_id Doctor ID
|
||||
* @param string $start_date Start date
|
||||
* @param string $end_date End date
|
||||
* @return array Available slots
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_doctor_availability( $doctor_id, $start_date, $end_date ) {
|
||||
global $wpdb;
|
||||
|
||||
// Get doctor schedule
|
||||
$doctor_schedule = get_option( "kivicare_doctor_{$doctor_id}_schedule", array() );
|
||||
|
||||
if ( empty( $doctor_schedule ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
// Get existing appointments in date range
|
||||
$existing_appointments = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT appointment_start_date, appointment_start_time, appointment_end_time, duration
|
||||
FROM {$wpdb->prefix}kc_appointments
|
||||
WHERE doctor_id = %d
|
||||
AND appointment_start_date BETWEEN %s AND %s
|
||||
AND status NOT IN (%d, %d)",
|
||||
$doctor_id, $start_date, $end_date,
|
||||
self::STATUS_CANCELLED, self::STATUS_NO_SHOW
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
// Calculate available slots
|
||||
$available_slots = array();
|
||||
$current_date = new \DateTime( $start_date );
|
||||
$end_date_obj = new \DateTime( $end_date );
|
||||
|
||||
while ( $current_date <= $end_date_obj ) {
|
||||
$day_name = strtolower( $current_date->format( 'l' ) );
|
||||
|
||||
if ( isset( $doctor_schedule[$day_name] ) && ! isset( $doctor_schedule[$day_name]['closed'] ) ) {
|
||||
$day_slots = self::calculate_day_slots(
|
||||
$current_date->format( 'Y-m-d' ),
|
||||
$doctor_schedule[$day_name],
|
||||
$existing_appointments
|
||||
);
|
||||
|
||||
if ( ! empty( $day_slots ) ) {
|
||||
$available_slots[$current_date->format( 'Y-m-d' )] = $day_slots;
|
||||
}
|
||||
}
|
||||
|
||||
$current_date->add( new \DateInterval( 'P1D' ) );
|
||||
}
|
||||
|
||||
return $available_slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique appointment number
|
||||
*
|
||||
* @return string Appointment number
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function generate_appointment_number() {
|
||||
global $wpdb;
|
||||
|
||||
$date_prefix = current_time( 'Ymd' );
|
||||
|
||||
// Get the highest existing appointment number for today
|
||||
$max_number = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT MAX(CAST(SUBSTRING(appointment_number, 9) AS UNSIGNED))
|
||||
FROM {$wpdb->prefix}kc_appointments
|
||||
WHERE appointment_number LIKE %s",
|
||||
$date_prefix . '%'
|
||||
)
|
||||
);
|
||||
|
||||
$next_number = ( $max_number ? $max_number + 1 : 1 );
|
||||
|
||||
return $date_prefix . str_pad( $next_number, 4, '0', STR_PAD_LEFT );
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate appointment end time
|
||||
*
|
||||
* @param string $start_time Start time
|
||||
* @param int $duration Duration in minutes
|
||||
* @return string End time
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function calculate_end_time( $start_time, $duration ) {
|
||||
$start_datetime = new \DateTime( $start_time );
|
||||
$start_datetime->add( new \DateInterval( "PT{$duration}M" ) );
|
||||
return $start_datetime->format( 'H:i:s' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate appointment business rules
|
||||
*
|
||||
* @param array $appointment_data Appointment data
|
||||
* @param int $appointment_id Appointment ID (for updates)
|
||||
* @return bool|WP_Error True if valid, WP_Error otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_appointment_business_rules( $appointment_data, $appointment_id = null ) {
|
||||
$errors = array();
|
||||
|
||||
// Validate required fields
|
||||
$required_fields = array( 'patient_id', 'doctor_id', 'clinic_id', 'appointment_start_date', 'appointment_start_time' );
|
||||
|
||||
foreach ( $required_fields as $field ) {
|
||||
if ( empty( $appointment_data[$field] ) ) {
|
||||
$errors[] = "Field {$field} is required";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate appointment date is not in the past
|
||||
if ( ! empty( $appointment_data['appointment_start_date'] ) ) {
|
||||
$appointment_date = new \DateTime( $appointment_data['appointment_start_date'] );
|
||||
$today = new \DateTime();
|
||||
$today->setTime( 0, 0, 0 );
|
||||
|
||||
if ( $appointment_date < $today ) {
|
||||
$errors[] = 'Appointment date cannot be in the past';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate patient exists
|
||||
if ( ! empty( $appointment_data['patient_id'] ) ) {
|
||||
global $wpdb;
|
||||
$patient_exists = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$wpdb->prefix}kc_patients WHERE id = %d",
|
||||
$appointment_data['patient_id']
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! $patient_exists ) {
|
||||
$errors[] = 'Invalid patient ID';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate doctor exists
|
||||
if ( ! empty( $appointment_data['doctor_id'] ) ) {
|
||||
global $wpdb;
|
||||
$doctor_exists = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$wpdb->prefix}kc_doctors WHERE id = %d",
|
||||
$appointment_data['doctor_id']
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! $doctor_exists ) {
|
||||
$errors[] = 'Invalid doctor ID';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate clinic exists
|
||||
if ( ! empty( $appointment_data['clinic_id'] ) ) {
|
||||
global $wpdb;
|
||||
$clinic_exists = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$wpdb->prefix}kc_clinics WHERE id = %d",
|
||||
$appointment_data['clinic_id']
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! $clinic_exists ) {
|
||||
$errors[] = 'Invalid clinic ID';
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $errors ) ) {
|
||||
return new \WP_Error(
|
||||
'appointment_business_validation_failed',
|
||||
'Appointment business validation failed',
|
||||
array(
|
||||
'status' => 400,
|
||||
'errors' => $errors
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check doctor availability for appointment slot
|
||||
*
|
||||
* @param array $appointment_data Appointment data
|
||||
* @param int $exclude_id Appointment ID to exclude (for updates)
|
||||
* @return bool|WP_Error True if available, WP_Error otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function check_doctor_availability( $appointment_data, $exclude_id = null ) {
|
||||
global $wpdb;
|
||||
|
||||
$doctor_id = $appointment_data['doctor_id'];
|
||||
$start_date = $appointment_data['appointment_start_date'];
|
||||
$start_time = $appointment_data['appointment_start_time'];
|
||||
$duration = $appointment_data['duration'] ?? 30;
|
||||
$end_time = self::calculate_end_time( $start_time, $duration );
|
||||
|
||||
// Check for conflicting appointments
|
||||
$conflict_query = "SELECT id FROM {$wpdb->prefix}kc_appointments
|
||||
WHERE doctor_id = %d
|
||||
AND appointment_start_date = %s
|
||||
AND status NOT IN (%d, %d)
|
||||
AND (
|
||||
(appointment_start_time <= %s AND appointment_end_time > %s) OR
|
||||
(appointment_start_time < %s AND appointment_end_time >= %s) OR
|
||||
(appointment_start_time >= %s AND appointment_end_time <= %s)
|
||||
)";
|
||||
|
||||
$conflict_params = array(
|
||||
$doctor_id, $start_date,
|
||||
self::STATUS_CANCELLED, self::STATUS_NO_SHOW,
|
||||
$start_time, $start_time,
|
||||
$end_time, $end_time,
|
||||
$start_time, $end_time
|
||||
);
|
||||
|
||||
if ( $exclude_id ) {
|
||||
$conflict_query .= " AND id != %d";
|
||||
$conflict_params[] = $exclude_id;
|
||||
}
|
||||
|
||||
$conflict = $wpdb->get_var( $wpdb->prepare( $conflict_query, $conflict_params ) );
|
||||
|
||||
if ( $conflict ) {
|
||||
return new \WP_Error(
|
||||
'doctor_not_available',
|
||||
'Doctor is not available at the selected time slot',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check doctor working hours
|
||||
$doctor_schedule = get_option( "kivicare_doctor_{$doctor_id}_schedule", array() );
|
||||
$day_name = strtolower( date( 'l', strtotime( $start_date ) ) );
|
||||
|
||||
if ( empty( $doctor_schedule[$day_name] ) || isset( $doctor_schedule[$day_name]['closed'] ) ) {
|
||||
return new \WP_Error(
|
||||
'doctor_not_working',
|
||||
'Doctor is not working on this day',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$working_hours = $doctor_schedule[$day_name];
|
||||
if ( $start_time < $working_hours['start_time'] || $end_time > $working_hours['end_time'] ) {
|
||||
return new \WP_Error(
|
||||
'outside_working_hours',
|
||||
'Appointment time is outside doctor working hours',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check break time if exists
|
||||
if ( isset( $working_hours['break_start'] ) && isset( $working_hours['break_end'] ) ) {
|
||||
$break_start = $working_hours['break_start'];
|
||||
$break_end = $working_hours['break_end'];
|
||||
|
||||
if ( ( $start_time >= $break_start && $start_time < $break_end ) ||
|
||||
( $end_time > $break_start && $end_time <= $break_end ) ||
|
||||
( $start_time <= $break_start && $end_time >= $break_end ) ) {
|
||||
return new \WP_Error(
|
||||
'during_break_time',
|
||||
'Appointment time conflicts with doctor break time',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional helper methods for appointment management
|
||||
*/
|
||||
|
||||
private static function is_appointment_rescheduling( $current_appointment, $new_data ) {
|
||||
return ( isset( $new_data['appointment_start_date'] ) && $new_data['appointment_start_date'] != $current_appointment['appointment_start_date'] ) ||
|
||||
( isset( $new_data['appointment_start_time'] ) && $new_data['appointment_start_time'] != $current_appointment['appointment_start_time'] ) ||
|
||||
( isset( $new_data['doctor_id'] ) && $new_data['doctor_id'] != $current_appointment['doctor_id'] );
|
||||
}
|
||||
|
||||
private static function handle_status_changes( $appointment_id, $current_appointment, $new_data ) {
|
||||
if ( isset( $new_data['status'] ) && $new_data['status'] != $current_appointment['status'] ) {
|
||||
$status_change = array(
|
||||
'appointment_id' => $appointment_id,
|
||||
'from_status' => $current_appointment['status'],
|
||||
'to_status' => $new_data['status'],
|
||||
'changed_by' => get_current_user_id(),
|
||||
'changed_at' => current_time( 'mysql' )
|
||||
);
|
||||
|
||||
do_action( 'kivicare_appointment_status_changed', $status_change );
|
||||
}
|
||||
}
|
||||
|
||||
private static function setup_appointment_defaults( $appointment_id, $appointment_data ) {
|
||||
// Setup any default values or related data
|
||||
update_option( "kivicare_appointment_{$appointment_id}_created", current_time( 'mysql' ) );
|
||||
}
|
||||
|
||||
private static function send_appointment_notifications( $appointment_id, $type ) {
|
||||
// Send notifications to patient, doctor, etc.
|
||||
do_action( "kivicare_send_appointment_{$type}_notification", $appointment_id );
|
||||
}
|
||||
|
||||
private static function auto_generate_bill( $appointment_id ) {
|
||||
// Auto-generate bill for completed appointment
|
||||
do_action( 'kivicare_auto_generate_bill', $appointment_id );
|
||||
}
|
||||
|
||||
private static function get_appointment_patient( $patient_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, first_name, last_name, user_email, contact_no FROM {$wpdb->prefix}kc_patients WHERE id = %d",
|
||||
$patient_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_appointment_doctor( $doctor_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, first_name, last_name, user_email, mobile_number, specialties FROM {$wpdb->prefix}kc_doctors WHERE id = %d",
|
||||
$doctor_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_appointment_clinic( $clinic_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, name, address, city, telephone_no FROM {$wpdb->prefix}kc_clinics WHERE id = %d",
|
||||
$clinic_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_appointment_service( $service_id ) {
|
||||
if ( ! $service_id ) return null;
|
||||
|
||||
global $wpdb;
|
||||
return $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, name, price, duration FROM {$wpdb->prefix}kc_services WHERE id = %d",
|
||||
$service_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_appointment_encounters( $appointment_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_encounters WHERE appointment_id = %d ORDER BY encounter_date DESC",
|
||||
$appointment_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_appointment_bills( $appointment_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_bills WHERE appointment_id = %d ORDER BY created_at DESC",
|
||||
$appointment_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_status_label( $status ) {
|
||||
$labels = array(
|
||||
self::STATUS_BOOKED => 'Booked',
|
||||
self::STATUS_COMPLETED => 'Completed',
|
||||
self::STATUS_CANCELLED => 'Cancelled',
|
||||
self::STATUS_NO_SHOW => 'No Show',
|
||||
self::STATUS_RESCHEDULED => 'Rescheduled'
|
||||
);
|
||||
|
||||
return $labels[$status] ?? 'Unknown';
|
||||
}
|
||||
|
||||
private static function calculate_day_slots( $date, $schedule, $existing_appointments ) {
|
||||
$slots = array();
|
||||
$slot_duration = 30; // minutes
|
||||
|
||||
$start_time = new \DateTime( $date . ' ' . $schedule['start_time'] );
|
||||
$end_time = new \DateTime( $date . ' ' . $schedule['end_time'] );
|
||||
|
||||
// Handle break time
|
||||
$break_start = isset( $schedule['break_start'] ) ? new \DateTime( $date . ' ' . $schedule['break_start'] ) : null;
|
||||
$break_end = isset( $schedule['break_end'] ) ? new \DateTime( $date . ' ' . $schedule['break_end'] ) : null;
|
||||
|
||||
$current_time = clone $start_time;
|
||||
|
||||
while ( $current_time < $end_time ) {
|
||||
$slot_end = clone $current_time;
|
||||
$slot_end->add( new \DateInterval( "PT{$slot_duration}M" ) );
|
||||
|
||||
// Skip if in break time
|
||||
if ( $break_start && $break_end &&
|
||||
( $current_time >= $break_start && $current_time < $break_end ) ) {
|
||||
$current_time = clone $break_end;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if slot is available
|
||||
$is_available = true;
|
||||
foreach ( $existing_appointments as $appointment ) {
|
||||
if ( $appointment['appointment_start_date'] == $date ) {
|
||||
$app_start = new \DateTime( $date . ' ' . $appointment['appointment_start_time'] );
|
||||
$app_end = new \DateTime( $date . ' ' . $appointment['appointment_end_time'] );
|
||||
|
||||
if ( ( $current_time >= $app_start && $current_time < $app_end ) ||
|
||||
( $slot_end > $app_start && $slot_end <= $app_end ) ||
|
||||
( $current_time <= $app_start && $slot_end >= $app_end ) ) {
|
||||
$is_available = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( $is_available ) {
|
||||
$slots[] = array(
|
||||
'start_time' => $current_time->format( 'H:i:s' ),
|
||||
'end_time' => $slot_end->format( 'H:i:s' ),
|
||||
'duration' => $slot_duration
|
||||
);
|
||||
}
|
||||
|
||||
$current_time->add( new \DateInterval( "PT{$slot_duration}M" ) );
|
||||
}
|
||||
|
||||
return $slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handlers
|
||||
*/
|
||||
public static function on_appointment_created( $appointment_id, $appointment_data ) {
|
||||
error_log( "KiviCare: New appointment created - ID: {$appointment_id}, Patient: " . ( $appointment_data['patient_id'] ?? 'Unknown' ) );
|
||||
}
|
||||
|
||||
public static function on_appointment_updated( $appointment_id, $appointment_data ) {
|
||||
error_log( "KiviCare: Appointment updated - ID: {$appointment_id}" );
|
||||
wp_cache_delete( "appointment_{$appointment_id}", 'kivicare' );
|
||||
}
|
||||
|
||||
public static function on_appointment_cancelled( $appointment_id ) {
|
||||
error_log( "KiviCare: Appointment cancelled - ID: {$appointment_id}" );
|
||||
wp_cache_delete( "appointment_{$appointment_id}", 'kivicare' );
|
||||
}
|
||||
|
||||
public static function on_appointment_completed( $appointment_id ) {
|
||||
error_log( "KiviCare: Appointment completed - ID: {$appointment_id}" );
|
||||
wp_cache_delete( "appointment_{$appointment_id}", 'kivicare' );
|
||||
}
|
||||
}
|
||||
1049
src/includes/services/database/class-bill-service.php
Normal file
1049
src/includes/services/database/class-bill-service.php
Normal file
File diff suppressed because it is too large
Load Diff
810
src/includes/services/database/class-clinic-service.php
Normal file
810
src/includes/services/database/class-clinic-service.php
Normal file
@@ -0,0 +1,810 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Clinic Database Service
|
||||
*
|
||||
* Handles advanced clinic data operations and business logic
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Services\Database
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Services\Database;
|
||||
|
||||
use KiviCare_API\Models\Clinic;
|
||||
use KiviCare_API\Services\Permission_Service;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Clinic_Service
|
||||
*
|
||||
* Advanced database service for clinic management with business logic
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Clinic_Service {
|
||||
|
||||
/**
|
||||
* Initialize the service
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function init() {
|
||||
// Hook into WordPress actions
|
||||
add_action( 'kivicare_clinic_created', array( self::class, 'on_clinic_created' ), 10, 2 );
|
||||
add_action( 'kivicare_clinic_updated', array( self::class, 'on_clinic_updated' ), 10, 2 );
|
||||
add_action( 'kivicare_clinic_deleted', array( self::class, 'on_clinic_deleted' ), 10, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create clinic with advanced business logic
|
||||
*
|
||||
* @param array $clinic_data Clinic data
|
||||
* @param int $user_id Creating user ID
|
||||
* @return array|WP_Error Clinic data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function create_clinic( $clinic_data, $user_id = null ) {
|
||||
// Permission check
|
||||
if ( ! Permission_Service::current_user_can( 'manage_clinics' ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'You do not have permission to create clinics',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced validation
|
||||
$validation = self::validate_clinic_business_rules( $clinic_data );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
$clinic_data['created_by'] = $user_id ?: get_current_user_id();
|
||||
$clinic_data['created_at'] = current_time( 'mysql' );
|
||||
|
||||
// Create clinic
|
||||
$clinic_id = Clinic::create( $clinic_data );
|
||||
|
||||
if ( is_wp_error( $clinic_id ) ) {
|
||||
return $clinic_id;
|
||||
}
|
||||
|
||||
// Post-creation tasks
|
||||
self::setup_clinic_defaults( $clinic_id, $clinic_data );
|
||||
|
||||
// Trigger action
|
||||
do_action( 'kivicare_clinic_created', $clinic_id, $clinic_data );
|
||||
|
||||
// Return full clinic data
|
||||
return self::get_clinic_with_metadata( $clinic_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Update clinic with business logic
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @param array $clinic_data Updated data
|
||||
* @return array|WP_Error Updated clinic data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function update_clinic( $clinic_id, $clinic_data ) {
|
||||
// Permission check
|
||||
if ( ! Permission_Service::current_user_can( 'manage_clinics' ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'You do not have permission to update clinics',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check if clinic exists
|
||||
if ( ! Clinic::exists( $clinic_id ) ) {
|
||||
return new \WP_Error(
|
||||
'clinic_not_found',
|
||||
'Clinic not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Get current data for comparison
|
||||
$current_data = Clinic::get_by_id( $clinic_id );
|
||||
|
||||
// Enhanced validation
|
||||
$validation = self::validate_clinic_business_rules( $clinic_data, $clinic_id );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
// Add update metadata
|
||||
$clinic_data['updated_by'] = get_current_user_id();
|
||||
$clinic_data['updated_at'] = current_time( 'mysql' );
|
||||
|
||||
// Update clinic
|
||||
$result = Clinic::update( $clinic_id, $clinic_data );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Handle specialty changes
|
||||
self::handle_specialty_changes( $clinic_id, $current_data, $clinic_data );
|
||||
|
||||
// Trigger action
|
||||
do_action( 'kivicare_clinic_updated', $clinic_id, $clinic_data );
|
||||
|
||||
// Return updated clinic data
|
||||
return self::get_clinic_with_metadata( $clinic_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clinic with enhanced metadata
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return array|WP_Error Clinic data with metadata or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_clinic_with_metadata( $clinic_id ) {
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_access_clinic( get_current_user_id(), $clinic_id ) ) {
|
||||
return new \WP_Error(
|
||||
'access_denied',
|
||||
'You do not have access to this clinic',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
$clinic = Clinic::get_by_id( $clinic_id );
|
||||
|
||||
if ( ! $clinic ) {
|
||||
return new \WP_Error(
|
||||
'clinic_not_found',
|
||||
'Clinic not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Add enhanced metadata
|
||||
$clinic['statistics'] = self::get_clinic_statistics( $clinic_id );
|
||||
$clinic['doctors'] = self::get_clinic_doctors( $clinic_id );
|
||||
$clinic['services'] = self::get_clinic_services( $clinic_id );
|
||||
$clinic['working_hours'] = self::get_clinic_working_hours( $clinic_id );
|
||||
$clinic['contact_info'] = self::get_clinic_contact_info( $clinic_id );
|
||||
|
||||
return $clinic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clinics accessible to user with filters
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return array Clinics with metadata
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_accessible_clinics( $args = array() ) {
|
||||
$user_id = get_current_user_id();
|
||||
$accessible_clinic_ids = Permission_Service::get_accessible_clinic_ids( $user_id );
|
||||
|
||||
if ( empty( $accessible_clinic_ids ) ) {
|
||||
return array(
|
||||
'clinics' => array(),
|
||||
'total' => 0,
|
||||
'has_more' => false
|
||||
);
|
||||
}
|
||||
|
||||
// Add clinic filter to args
|
||||
$args['clinic_ids'] = $accessible_clinic_ids;
|
||||
|
||||
$defaults = array(
|
||||
'limit' => 20,
|
||||
'offset' => 0,
|
||||
'status' => 1,
|
||||
'include_statistics' => false,
|
||||
'include_doctors' => false,
|
||||
'include_services' => false
|
||||
);
|
||||
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
|
||||
// Get clinics with custom query for better performance
|
||||
$clinics = self::query_clinics_with_filters( $args );
|
||||
|
||||
// Add metadata if requested
|
||||
if ( $args['include_statistics'] || $args['include_doctors'] || $args['include_services'] ) {
|
||||
foreach ( $clinics as &$clinic ) {
|
||||
if ( $args['include_statistics'] ) {
|
||||
$clinic['statistics'] = self::get_clinic_statistics( $clinic['id'] );
|
||||
}
|
||||
if ( $args['include_doctors'] ) {
|
||||
$clinic['doctors'] = self::get_clinic_doctors( $clinic['id'] );
|
||||
}
|
||||
if ( $args['include_services'] ) {
|
||||
$clinic['services'] = self::get_clinic_services( $clinic['id'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
$total = self::count_clinics_with_filters( $args );
|
||||
|
||||
return array(
|
||||
'clinics' => $clinics,
|
||||
'total' => $total,
|
||||
'has_more' => ( $args['offset'] + $args['limit'] ) < $total
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search clinics with advanced criteria
|
||||
*
|
||||
* @param string $search_term Search term
|
||||
* @param array $filters Additional filters
|
||||
* @return array Search results
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function search_clinics( $search_term, $filters = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$accessible_clinic_ids = Permission_Service::get_accessible_clinic_ids( $user_id );
|
||||
|
||||
if ( empty( $accessible_clinic_ids ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
// Build search query
|
||||
$where_clauses = array( "c.id IN (" . implode( ',', $accessible_clinic_ids ) . ")" );
|
||||
$where_values = array();
|
||||
|
||||
// Search term
|
||||
if ( ! empty( $search_term ) ) {
|
||||
$where_clauses[] = "(c.name LIKE %s OR c.address LIKE %s OR c.city LIKE %s OR c.specialties LIKE %s)";
|
||||
$search_term = '%' . $wpdb->esc_like( $search_term ) . '%';
|
||||
$where_values = array_merge( $where_values, array_fill( 0, 4, $search_term ) );
|
||||
}
|
||||
|
||||
// Location filter
|
||||
if ( ! empty( $filters['city'] ) ) {
|
||||
$where_clauses[] = "c.city = %s";
|
||||
$where_values[] = $filters['city'];
|
||||
}
|
||||
|
||||
if ( ! empty( $filters['state'] ) ) {
|
||||
$where_clauses[] = "c.state = %s";
|
||||
$where_values[] = $filters['state'];
|
||||
}
|
||||
|
||||
// Specialty filter
|
||||
if ( ! empty( $filters['specialty'] ) ) {
|
||||
$where_clauses[] = "c.specialties LIKE %s";
|
||||
$where_values[] = '%' . $wpdb->esc_like( $filters['specialty'] ) . '%';
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$query = "SELECT c.*,
|
||||
COUNT(DISTINCT dcm.doctor_id) as doctor_count,
|
||||
COUNT(DISTINCT pcm.patient_id) as patient_count
|
||||
FROM {$wpdb->prefix}kc_clinics c
|
||||
LEFT JOIN {$wpdb->prefix}kc_doctor_clinic_mappings dcm ON c.id = dcm.clinic_id
|
||||
LEFT JOIN {$wpdb->prefix}kc_patient_clinic_mappings pcm ON c.id = pcm.clinic_id
|
||||
WHERE {$where_sql}
|
||||
GROUP BY c.id
|
||||
ORDER BY c.name ASC
|
||||
LIMIT 20";
|
||||
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$results = $wpdb->get_results( $wpdb->prepare( $query, $where_values ), ARRAY_A );
|
||||
} else {
|
||||
$results = $wpdb->get_results( $query, ARRAY_A );
|
||||
}
|
||||
|
||||
return array_map( function( $clinic ) {
|
||||
$clinic['id'] = (int) $clinic['id'];
|
||||
$clinic['doctor_count'] = (int) $clinic['doctor_count'];
|
||||
$clinic['patient_count'] = (int) $clinic['patient_count'];
|
||||
return $clinic;
|
||||
}, $results );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clinic dashboard data
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return array|WP_Error Dashboard data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_clinic_dashboard( $clinic_id ) {
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_access_clinic( get_current_user_id(), $clinic_id ) ) {
|
||||
return new \WP_Error(
|
||||
'access_denied',
|
||||
'You do not have access to this clinic dashboard',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
$dashboard = array();
|
||||
|
||||
// Basic clinic info
|
||||
$dashboard['clinic'] = Clinic::get_by_id( $clinic_id );
|
||||
|
||||
if ( ! $dashboard['clinic'] ) {
|
||||
return new \WP_Error(
|
||||
'clinic_not_found',
|
||||
'Clinic not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Statistics
|
||||
$dashboard['statistics'] = self::get_comprehensive_statistics( $clinic_id );
|
||||
|
||||
// Recent activity
|
||||
$dashboard['recent_appointments'] = self::get_recent_appointments( $clinic_id, 10 );
|
||||
$dashboard['recent_patients'] = self::get_recent_patients( $clinic_id, 10 );
|
||||
|
||||
// Performance metrics
|
||||
$dashboard['performance'] = self::get_performance_metrics( $clinic_id );
|
||||
|
||||
// Alerts and notifications
|
||||
$dashboard['alerts'] = self::get_clinic_alerts( $clinic_id );
|
||||
|
||||
return $dashboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clinic performance metrics
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return array Performance metrics
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_performance_metrics( $clinic_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$metrics = array();
|
||||
|
||||
// Appointment completion rate (last 30 days)
|
||||
$completion_data = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT
|
||||
COUNT(*) as total_appointments,
|
||||
SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) as completed_appointments,
|
||||
SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) as cancelled_appointments,
|
||||
SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) as no_show_appointments
|
||||
FROM {$wpdb->prefix}kc_appointments
|
||||
WHERE clinic_id = %d
|
||||
AND appointment_start_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)",
|
||||
$clinic_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( $completion_data && $completion_data['total_appointments'] > 0 ) {
|
||||
$total = (int) $completion_data['total_appointments'];
|
||||
$metrics['completion_rate'] = round( ( (int) $completion_data['completed_appointments'] / $total ) * 100, 1 );
|
||||
$metrics['cancellation_rate'] = round( ( (int) $completion_data['cancelled_appointments'] / $total ) * 100, 1 );
|
||||
$metrics['no_show_rate'] = round( ( (int) $completion_data['no_show_appointments'] / $total ) * 100, 1 );
|
||||
} else {
|
||||
$metrics['completion_rate'] = 0;
|
||||
$metrics['cancellation_rate'] = 0;
|
||||
$metrics['no_show_rate'] = 0;
|
||||
}
|
||||
|
||||
// Average patient wait time (would require additional tracking)
|
||||
$metrics['avg_wait_time'] = 0; // Placeholder
|
||||
|
||||
// Revenue trend (last 3 months)
|
||||
$metrics['revenue_trend'] = self::get_revenue_trend( $clinic_id, 3 );
|
||||
|
||||
// Utilization rate (appointments vs. available slots)
|
||||
$metrics['utilization_rate'] = self::calculate_utilization_rate( $clinic_id );
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate clinic business rules
|
||||
*
|
||||
* @param array $clinic_data Clinic data
|
||||
* @param int $clinic_id Clinic ID (for updates)
|
||||
* @return bool|WP_Error True if valid, WP_Error otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_clinic_business_rules( $clinic_data, $clinic_id = null ) {
|
||||
global $wpdb;
|
||||
|
||||
$errors = array();
|
||||
|
||||
// Check for duplicate clinic name in same city
|
||||
if ( ! empty( $clinic_data['name'] ) && ! empty( $clinic_data['city'] ) ) {
|
||||
$existing_query = "SELECT id FROM {$wpdb->prefix}kc_clinics WHERE name = %s AND city = %s";
|
||||
$query_params = array( $clinic_data['name'], $clinic_data['city'] );
|
||||
|
||||
if ( $clinic_id ) {
|
||||
$existing_query .= " AND id != %d";
|
||||
$query_params[] = $clinic_id;
|
||||
}
|
||||
|
||||
$existing_clinic = $wpdb->get_var( $wpdb->prepare( $existing_query, $query_params ) );
|
||||
|
||||
if ( $existing_clinic ) {
|
||||
$errors[] = 'A clinic with this name already exists in the same city';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate contact information format
|
||||
if ( ! empty( $clinic_data['telephone_no'] ) ) {
|
||||
if ( ! preg_match( '/^[+]?[0-9\s\-\(\)]{7,20}$/', $clinic_data['telephone_no'] ) ) {
|
||||
$errors[] = 'Invalid telephone number format';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate specialties if provided
|
||||
if ( ! empty( $clinic_data['specialties'] ) ) {
|
||||
$specialties = is_array( $clinic_data['specialties'] ) ?
|
||||
$clinic_data['specialties'] :
|
||||
json_decode( $clinic_data['specialties'], true );
|
||||
|
||||
if ( is_array( $specialties ) ) {
|
||||
$valid_specialties = self::get_valid_specialties();
|
||||
foreach ( $specialties as $specialty ) {
|
||||
if ( ! in_array( $specialty, $valid_specialties ) ) {
|
||||
$errors[] = "Invalid specialty: {$specialty}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate clinic admin if provided
|
||||
if ( ! empty( $clinic_data['clinic_admin_id'] ) ) {
|
||||
$admin_user = get_user_by( 'id', $clinic_data['clinic_admin_id'] );
|
||||
if ( ! $admin_user || ! in_array( 'kivicare_doctor', $admin_user->roles ) ) {
|
||||
$errors[] = 'Clinic admin must be a valid doctor user';
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $errors ) ) {
|
||||
return new \WP_Error(
|
||||
'clinic_business_validation_failed',
|
||||
'Clinic business validation failed',
|
||||
array(
|
||||
'status' => 400,
|
||||
'errors' => $errors
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup clinic defaults after creation
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @param array $clinic_data Clinic data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function setup_clinic_defaults( $clinic_id, $clinic_data ) {
|
||||
// Create default services
|
||||
self::create_default_services( $clinic_id );
|
||||
|
||||
// Setup default working hours
|
||||
self::setup_default_working_hours( $clinic_id );
|
||||
|
||||
// Create default appointment slots
|
||||
self::setup_default_appointment_slots( $clinic_id );
|
||||
|
||||
// Initialize clinic settings
|
||||
self::initialize_clinic_settings( $clinic_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default services for new clinic
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function create_default_services( $clinic_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$default_services = array(
|
||||
array( 'name' => 'General Consultation', 'type' => 'consultation', 'price' => 50.00, 'duration' => 30 ),
|
||||
array( 'name' => 'Follow-up Visit', 'type' => 'consultation', 'price' => 30.00, 'duration' => 15 ),
|
||||
array( 'name' => 'Health Checkup', 'type' => 'checkup', 'price' => 80.00, 'duration' => 45 )
|
||||
);
|
||||
|
||||
foreach ( $default_services as $service ) {
|
||||
$wpdb->insert(
|
||||
$wpdb->prefix . 'kc_services',
|
||||
array_merge( $service, array(
|
||||
'clinic_id' => $clinic_id,
|
||||
'status' => 1,
|
||||
'created_at' => current_time( 'mysql' )
|
||||
) )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup default working hours
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function setup_default_working_hours( $clinic_id ) {
|
||||
$default_hours = array(
|
||||
'monday' => array( 'start_time' => '09:00', 'end_time' => '17:00' ),
|
||||
'tuesday' => array( 'start_time' => '09:00', 'end_time' => '17:00' ),
|
||||
'wednesday' => array( 'start_time' => '09:00', 'end_time' => '17:00' ),
|
||||
'thursday' => array( 'start_time' => '09:00', 'end_time' => '17:00' ),
|
||||
'friday' => array( 'start_time' => '09:00', 'end_time' => '17:00' ),
|
||||
'saturday' => array( 'start_time' => '09:00', 'end_time' => '13:00' ),
|
||||
'sunday' => array( 'closed' => true )
|
||||
);
|
||||
|
||||
update_option( "kivicare_clinic_{$clinic_id}_working_hours", $default_hours );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clinic statistics
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return array Statistics
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_clinic_statistics( $clinic_id ) {
|
||||
return Clinic::get_statistics( $clinic_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clinic doctors
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return array Doctors
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_clinic_doctors( $clinic_id ) {
|
||||
global $wpdb;
|
||||
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT u.ID, u.display_name, u.user_email,
|
||||
COUNT(a.id) as total_appointments
|
||||
FROM {$wpdb->prefix}kc_doctor_clinic_mappings dcm
|
||||
JOIN {$wpdb->prefix}users u ON dcm.doctor_id = u.ID
|
||||
LEFT JOIN {$wpdb->prefix}kc_appointments a ON u.ID = a.doctor_id AND a.clinic_id = %d
|
||||
WHERE dcm.clinic_id = %d
|
||||
GROUP BY u.ID
|
||||
ORDER BY u.display_name",
|
||||
$clinic_id, $clinic_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clinic services
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return array Services
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_clinic_services( $clinic_id ) {
|
||||
global $wpdb;
|
||||
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_services WHERE clinic_id = %d AND status = 1 ORDER BY name",
|
||||
$clinic_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clinic working hours
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return array Working hours
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_clinic_working_hours( $clinic_id ) {
|
||||
return get_option( "kivicare_clinic_{$clinic_id}_working_hours", array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clinic contact information
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return array Contact info
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_clinic_contact_info( $clinic_id ) {
|
||||
$clinic = Clinic::get_by_id( $clinic_id );
|
||||
|
||||
if ( ! $clinic ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
return array(
|
||||
'email' => $clinic['email'],
|
||||
'telephone' => $clinic['telephone_no'],
|
||||
'address' => $clinic['address'],
|
||||
'city' => $clinic['city'],
|
||||
'state' => $clinic['state'],
|
||||
'country' => $clinic['country'],
|
||||
'postal_code' => $clinic['postal_code']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid medical specialties
|
||||
*
|
||||
* @return array Valid specialties
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_valid_specialties() {
|
||||
return array(
|
||||
'general_medicine', 'cardiology', 'dermatology', 'endocrinology',
|
||||
'gastroenterology', 'gynecology', 'neurology', 'oncology',
|
||||
'ophthalmology', 'orthopedics', 'otolaryngology', 'pediatrics',
|
||||
'psychiatry', 'pulmonology', 'radiology', 'urology'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query clinics with custom filters
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return array Clinics
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function query_clinics_with_filters( $args ) {
|
||||
global $wpdb;
|
||||
|
||||
$where_clauses = array( '1=1' );
|
||||
$where_values = array();
|
||||
|
||||
if ( ! empty( $args['clinic_ids'] ) ) {
|
||||
$placeholders = implode( ',', array_fill( 0, count( $args['clinic_ids'] ), '%d' ) );
|
||||
$where_clauses[] = "id IN ({$placeholders})";
|
||||
$where_values = array_merge( $where_values, $args['clinic_ids'] );
|
||||
}
|
||||
|
||||
if ( isset( $args['status'] ) ) {
|
||||
$where_clauses[] = 'status = %d';
|
||||
$where_values[] = $args['status'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$query = "SELECT * FROM {$wpdb->prefix}kc_clinics WHERE {$where_sql} ORDER BY name ASC LIMIT %d OFFSET %d";
|
||||
$where_values[] = $args['limit'];
|
||||
$where_values[] = $args['offset'];
|
||||
|
||||
return $wpdb->get_results( $wpdb->prepare( $query, $where_values ), ARRAY_A );
|
||||
}
|
||||
|
||||
/**
|
||||
* Count clinics with filters
|
||||
*
|
||||
* @param array $args Query arguments
|
||||
* @return int Count
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function count_clinics_with_filters( $args ) {
|
||||
global $wpdb;
|
||||
|
||||
$where_clauses = array( '1=1' );
|
||||
$where_values = array();
|
||||
|
||||
if ( ! empty( $args['clinic_ids'] ) ) {
|
||||
$placeholders = implode( ',', array_fill( 0, count( $args['clinic_ids'] ), '%d' ) );
|
||||
$where_clauses[] = "id IN ({$placeholders})";
|
||||
$where_values = array_merge( $where_values, $args['clinic_ids'] );
|
||||
}
|
||||
|
||||
if ( isset( $args['status'] ) ) {
|
||||
$where_clauses[] = 'status = %d';
|
||||
$where_values[] = $args['status'];
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
$query = "SELECT COUNT(*) FROM {$wpdb->prefix}kc_clinics WHERE {$where_sql}";
|
||||
|
||||
if ( ! empty( $where_values ) ) {
|
||||
return (int) $wpdb->get_var( $wpdb->prepare( $query, $where_values ) );
|
||||
}
|
||||
|
||||
return (int) $wpdb->get_var( $query );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clinic specialty changes
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @param array $current_data Current clinic data
|
||||
* @param array $new_data New clinic data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function handle_specialty_changes( $clinic_id, $current_data, $new_data ) {
|
||||
if ( ! isset( $new_data['specialties'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$old_specialties = is_array( $current_data['specialties'] ) ?
|
||||
$current_data['specialties'] :
|
||||
json_decode( $current_data['specialties'] ?? '[]', true );
|
||||
|
||||
$new_specialties = is_array( $new_data['specialties'] ) ?
|
||||
$new_data['specialties'] :
|
||||
json_decode( $new_data['specialties'], true );
|
||||
|
||||
// Trigger action if specialties changed
|
||||
if ( $old_specialties !== $new_specialties ) {
|
||||
do_action( 'kivicare_clinic_specialties_changed', $clinic_id, $old_specialties, $new_specialties );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler: Clinic created
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @param array $clinic_data Clinic data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function on_clinic_created( $clinic_id, $clinic_data ) {
|
||||
// Log the creation
|
||||
error_log( "KiviCare: New clinic created - ID: {$clinic_id}, Name: " . ( $clinic_data['name'] ?? 'Unknown' ) );
|
||||
|
||||
// Could trigger notifications, integrations, etc.
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler: Clinic updated
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @param array $clinic_data Updated clinic data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function on_clinic_updated( $clinic_id, $clinic_data ) {
|
||||
// Log the update
|
||||
error_log( "KiviCare: Clinic updated - ID: {$clinic_id}" );
|
||||
|
||||
// Clear related caches
|
||||
wp_cache_delete( "clinic_{$clinic_id}", 'kivicare' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler: Clinic deleted
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function on_clinic_deleted( $clinic_id ) {
|
||||
// Clean up related data
|
||||
delete_option( "kivicare_clinic_{$clinic_id}_working_hours" );
|
||||
delete_option( "kivicare_clinic_{$clinic_id}_settings" );
|
||||
|
||||
// Clear caches
|
||||
wp_cache_delete( "clinic_{$clinic_id}", 'kivicare' );
|
||||
|
||||
// Log the deletion
|
||||
error_log( "KiviCare: Clinic deleted - ID: {$clinic_id}" );
|
||||
}
|
||||
}
|
||||
919
src/includes/services/database/class-doctor-service.php
Normal file
919
src/includes/services/database/class-doctor-service.php
Normal file
@@ -0,0 +1,919 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Doctor Database Service
|
||||
*
|
||||
* Handles advanced doctor data operations and business logic
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Services\Database
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Services\Database;
|
||||
|
||||
use KiviCare_API\Models\Doctor;
|
||||
use KiviCare_API\Services\Permission_Service;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Doctor_Service
|
||||
*
|
||||
* Advanced database service for doctor management with business logic
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Doctor_Service {
|
||||
|
||||
/**
|
||||
* Initialize the service
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function init() {
|
||||
// Hook into WordPress actions
|
||||
add_action( 'kivicare_doctor_created', array( self::class, 'on_doctor_created' ), 10, 2 );
|
||||
add_action( 'kivicare_doctor_updated', array( self::class, 'on_doctor_updated' ), 10, 2 );
|
||||
add_action( 'kivicare_doctor_deleted', array( self::class, 'on_doctor_deleted' ), 10, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create doctor with advanced business logic
|
||||
*
|
||||
* @param array $doctor_data Doctor data
|
||||
* @param int $clinic_id Primary clinic ID
|
||||
* @param int $user_id Creating user ID
|
||||
* @return array|WP_Error Doctor data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function create_doctor( $doctor_data, $clinic_id, $user_id = null ) {
|
||||
// Permission check
|
||||
if ( ! Permission_Service::current_user_can( 'manage_doctors' ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'You do not have permission to create doctors',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced validation
|
||||
$validation = self::validate_doctor_business_rules( $doctor_data, $clinic_id );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
// Create WordPress user first if email provided
|
||||
$wordpress_user_id = null;
|
||||
if ( ! empty( $doctor_data['user_email'] ) ) {
|
||||
$wordpress_user_id = self::create_doctor_wordpress_user( $doctor_data );
|
||||
if ( is_wp_error( $wordpress_user_id ) ) {
|
||||
return $wordpress_user_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
$doctor_data['clinic_id'] = $clinic_id;
|
||||
$doctor_data['user_id'] = $wordpress_user_id;
|
||||
$doctor_data['created_by'] = $user_id ?: get_current_user_id();
|
||||
$doctor_data['created_at'] = current_time( 'mysql' );
|
||||
|
||||
// Generate doctor ID if not provided
|
||||
if ( empty( $doctor_data['doctor_id'] ) ) {
|
||||
$doctor_data['doctor_id'] = self::generate_doctor_id( $clinic_id );
|
||||
}
|
||||
|
||||
// Create doctor
|
||||
$doctor_id = Doctor::create( $doctor_data );
|
||||
|
||||
if ( is_wp_error( $doctor_id ) ) {
|
||||
// Clean up WordPress user if created
|
||||
if ( $wordpress_user_id ) {
|
||||
wp_delete_user( $wordpress_user_id );
|
||||
}
|
||||
return $doctor_id;
|
||||
}
|
||||
|
||||
// Post-creation tasks
|
||||
self::setup_doctor_defaults( $doctor_id, $doctor_data );
|
||||
|
||||
// Create clinic association
|
||||
self::associate_doctor_with_clinic( $doctor_id, $clinic_id );
|
||||
|
||||
// Trigger action
|
||||
do_action( 'kivicare_doctor_created', $doctor_id, $doctor_data );
|
||||
|
||||
// Return full doctor data
|
||||
return self::get_doctor_with_metadata( $doctor_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Update doctor with business logic
|
||||
*
|
||||
* @param int $doctor_id Doctor ID
|
||||
* @param array $doctor_data Updated data
|
||||
* @return array|WP_Error Updated doctor data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function update_doctor( $doctor_id, $doctor_data ) {
|
||||
// Get current doctor data
|
||||
$current_doctor = Doctor::get_by_id( $doctor_id );
|
||||
if ( ! $current_doctor ) {
|
||||
return new \WP_Error(
|
||||
'doctor_not_found',
|
||||
'Doctor not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_manage_doctor( get_current_user_id(), $doctor_id ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'You do not have permission to update this doctor',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced validation
|
||||
$validation = self::validate_doctor_business_rules( $doctor_data, $current_doctor['clinic_id'], $doctor_id );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
// Handle WordPress user updates
|
||||
if ( ! empty( $doctor_data['user_email'] ) && $current_doctor['user_id'] ) {
|
||||
$wp_user_update = wp_update_user( array(
|
||||
'ID' => $current_doctor['user_id'],
|
||||
'user_email' => $doctor_data['user_email'],
|
||||
'display_name' => ( $doctor_data['first_name'] ?? '' ) . ' ' . ( $doctor_data['last_name'] ?? '' )
|
||||
) );
|
||||
|
||||
if ( is_wp_error( $wp_user_update ) ) {
|
||||
return new \WP_Error(
|
||||
'wordpress_user_update_failed',
|
||||
'Failed to update WordPress user: ' . $wp_user_update->get_error_message(),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add update metadata
|
||||
$doctor_data['updated_by'] = get_current_user_id();
|
||||
$doctor_data['updated_at'] = current_time( 'mysql' );
|
||||
|
||||
// Update doctor
|
||||
$result = Doctor::update( $doctor_id, $doctor_data );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Handle specialty changes
|
||||
self::handle_specialty_changes( $doctor_id, $current_doctor, $doctor_data );
|
||||
|
||||
// Handle clinic associations
|
||||
if ( isset( $doctor_data['additional_clinics'] ) ) {
|
||||
self::update_clinic_associations( $doctor_id, $doctor_data['additional_clinics'] );
|
||||
}
|
||||
|
||||
// Trigger action
|
||||
do_action( 'kivicare_doctor_updated', $doctor_id, $doctor_data );
|
||||
|
||||
// Return updated doctor data
|
||||
return self::get_doctor_with_metadata( $doctor_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doctor with enhanced metadata
|
||||
*
|
||||
* @param int $doctor_id Doctor ID
|
||||
* @return array|WP_Error Doctor data with metadata or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_doctor_with_metadata( $doctor_id ) {
|
||||
$doctor = Doctor::get_by_id( $doctor_id );
|
||||
|
||||
if ( ! $doctor ) {
|
||||
return new \WP_Error(
|
||||
'doctor_not_found',
|
||||
'Doctor not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_view_doctor( get_current_user_id(), $doctor_id ) ) {
|
||||
return new \WP_Error(
|
||||
'access_denied',
|
||||
'You do not have access to this doctor',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Add enhanced metadata
|
||||
$doctor['clinics'] = self::get_doctor_clinics( $doctor_id );
|
||||
$doctor['specialties'] = self::get_doctor_specialties( $doctor_id );
|
||||
$doctor['schedule'] = self::get_doctor_schedule( $doctor_id );
|
||||
$doctor['statistics'] = self::get_doctor_statistics( $doctor_id );
|
||||
$doctor['recent_appointments'] = self::get_recent_appointments( $doctor_id, 5 );
|
||||
$doctor['qualifications'] = self::get_doctor_qualifications( $doctor_id );
|
||||
$doctor['availability'] = self::get_doctor_availability( $doctor_id );
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search doctors with advanced criteria
|
||||
*
|
||||
* @param string $search_term Search term
|
||||
* @param array $filters Additional filters
|
||||
* @return array Search results
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function search_doctors( $search_term, $filters = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$accessible_clinic_ids = Permission_Service::get_accessible_clinic_ids( $user_id );
|
||||
|
||||
if ( empty( $accessible_clinic_ids ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
// Build search query
|
||||
$where_clauses = array( "d.clinic_id IN (" . implode( ',', $accessible_clinic_ids ) . ")" );
|
||||
$where_values = array();
|
||||
|
||||
// Search term
|
||||
if ( ! empty( $search_term ) ) {
|
||||
$where_clauses[] = "(d.first_name LIKE %s OR d.last_name LIKE %s OR d.doctor_id LIKE %s OR d.mobile_number LIKE %s OR d.user_email LIKE %s OR d.specialties LIKE %s)";
|
||||
$search_term = '%' . $wpdb->esc_like( $search_term ) . '%';
|
||||
$where_values = array_merge( $where_values, array_fill( 0, 6, $search_term ) );
|
||||
}
|
||||
|
||||
// Specialty filter
|
||||
if ( ! empty( $filters['specialty'] ) ) {
|
||||
$where_clauses[] = "d.specialties LIKE %s";
|
||||
$where_values[] = '%' . $wpdb->esc_like( $filters['specialty'] ) . '%';
|
||||
}
|
||||
|
||||
// Clinic filter
|
||||
if ( ! empty( $filters['clinic_id'] ) && in_array( $filters['clinic_id'], $accessible_clinic_ids ) ) {
|
||||
$where_clauses[] = "d.clinic_id = %d";
|
||||
$where_values[] = $filters['clinic_id'];
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ( isset( $filters['status'] ) ) {
|
||||
$where_clauses[] = "d.status = %d";
|
||||
$where_values[] = $filters['status'];
|
||||
} else {
|
||||
$where_clauses[] = "d.status = 1"; // Active by default
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$query = "SELECT d.*,
|
||||
c.name as clinic_name,
|
||||
COUNT(DISTINCT a.id) as appointment_count,
|
||||
AVG(CASE WHEN a.status = 2 THEN 1 ELSE 0 END) as completion_rate
|
||||
FROM {$wpdb->prefix}kc_doctors d
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON d.clinic_id = c.id
|
||||
LEFT JOIN {$wpdb->prefix}kc_appointments a ON d.id = a.doctor_id
|
||||
WHERE {$where_sql}
|
||||
GROUP BY d.id
|
||||
ORDER BY d.first_name, d.last_name
|
||||
LIMIT 50";
|
||||
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$results = $wpdb->get_results( $wpdb->prepare( $query, $where_values ), ARRAY_A );
|
||||
} else {
|
||||
$results = $wpdb->get_results( $query, ARRAY_A );
|
||||
}
|
||||
|
||||
return array_map( function( $doctor ) {
|
||||
$doctor['id'] = (int) $doctor['id'];
|
||||
$doctor['appointment_count'] = (int) $doctor['appointment_count'];
|
||||
$doctor['completion_rate'] = round( (float) $doctor['completion_rate'] * 100, 1 );
|
||||
return $doctor;
|
||||
}, $results );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doctor dashboard data
|
||||
*
|
||||
* @param int $doctor_id Doctor ID
|
||||
* @return array|WP_Error Dashboard data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_doctor_dashboard( $doctor_id ) {
|
||||
$doctor = Doctor::get_by_id( $doctor_id );
|
||||
|
||||
if ( ! $doctor ) {
|
||||
return new \WP_Error(
|
||||
'doctor_not_found',
|
||||
'Doctor not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_view_doctor( get_current_user_id(), $doctor_id ) ) {
|
||||
return new \WP_Error(
|
||||
'access_denied',
|
||||
'You do not have access to this doctor dashboard',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
$dashboard = array();
|
||||
|
||||
// Basic doctor info
|
||||
$dashboard['doctor'] = $doctor;
|
||||
|
||||
// Today's schedule
|
||||
$dashboard['todays_schedule'] = self::get_doctor_daily_schedule( $doctor_id, current_time( 'Y-m-d' ) );
|
||||
|
||||
// Statistics
|
||||
$dashboard['statistics'] = self::get_comprehensive_statistics( $doctor_id );
|
||||
|
||||
// Recent patients
|
||||
$dashboard['recent_patients'] = self::get_recent_patients( $doctor_id, 10 );
|
||||
|
||||
// Upcoming appointments
|
||||
$dashboard['upcoming_appointments'] = self::get_upcoming_appointments( $doctor_id );
|
||||
|
||||
// Performance metrics
|
||||
$dashboard['performance'] = self::get_performance_metrics( $doctor_id );
|
||||
|
||||
// Revenue data
|
||||
$dashboard['revenue'] = self::get_revenue_data( $doctor_id );
|
||||
|
||||
return $dashboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique doctor ID
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return string Doctor ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function generate_doctor_id( $clinic_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$prefix = 'D' . str_pad( $clinic_id, 3, '0', STR_PAD_LEFT );
|
||||
|
||||
// Get the highest existing doctor ID for this clinic
|
||||
$max_id = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT MAX(CAST(SUBSTRING(doctor_id, 5) AS UNSIGNED))
|
||||
FROM {$wpdb->prefix}kc_doctors
|
||||
WHERE clinic_id = %d AND doctor_id LIKE %s",
|
||||
$clinic_id,
|
||||
$prefix . '%'
|
||||
)
|
||||
);
|
||||
|
||||
$next_number = ( $max_id ? $max_id + 1 : 1 );
|
||||
|
||||
return $prefix . str_pad( $next_number, 4, '0', STR_PAD_LEFT );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create WordPress user for doctor
|
||||
*
|
||||
* @param array $doctor_data Doctor data
|
||||
* @return int|WP_Error WordPress user ID or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function create_doctor_wordpress_user( $doctor_data ) {
|
||||
$username = self::generate_username( $doctor_data );
|
||||
$password = wp_generate_password( 12, false );
|
||||
|
||||
$user_data = array(
|
||||
'user_login' => $username,
|
||||
'user_email' => $doctor_data['user_email'],
|
||||
'user_pass' => $password,
|
||||
'first_name' => $doctor_data['first_name'] ?? '',
|
||||
'last_name' => $doctor_data['last_name'] ?? '',
|
||||
'display_name' => ( $doctor_data['first_name'] ?? '' ) . ' ' . ( $doctor_data['last_name'] ?? '' ),
|
||||
'role' => 'kivicare_doctor'
|
||||
);
|
||||
|
||||
$user_id = wp_insert_user( $user_data );
|
||||
|
||||
if ( is_wp_error( $user_id ) ) {
|
||||
return new \WP_Error(
|
||||
'wordpress_user_creation_failed',
|
||||
'Failed to create WordPress user: ' . $user_id->get_error_message(),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
// Send welcome email with credentials
|
||||
self::send_doctor_welcome_email( $user_id, $username, $password );
|
||||
|
||||
return $user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique username
|
||||
*
|
||||
* @param array $doctor_data Doctor data
|
||||
* @return string Username
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function generate_username( $doctor_data ) {
|
||||
$first_name = sanitize_user( $doctor_data['first_name'] ?? '' );
|
||||
$last_name = sanitize_user( $doctor_data['last_name'] ?? '' );
|
||||
|
||||
$base_username = strtolower( $first_name . '.' . $last_name );
|
||||
$username = $base_username;
|
||||
$counter = 1;
|
||||
|
||||
while ( username_exists( $username ) ) {
|
||||
$username = $base_username . $counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
return $username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate doctor business rules
|
||||
*
|
||||
* @param array $doctor_data Doctor data
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @param int $doctor_id Doctor ID (for updates)
|
||||
* @return bool|WP_Error True if valid, WP_Error otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_doctor_business_rules( $doctor_data, $clinic_id, $doctor_id = null ) {
|
||||
global $wpdb;
|
||||
|
||||
$errors = array();
|
||||
|
||||
// Check for duplicate doctor ID in clinic
|
||||
if ( ! empty( $doctor_data['doctor_id'] ) ) {
|
||||
$existing_query = "SELECT id FROM {$wpdb->prefix}kc_doctors WHERE doctor_id = %s";
|
||||
$query_params = array( $doctor_data['doctor_id'] );
|
||||
|
||||
if ( $doctor_id ) {
|
||||
$existing_query .= " AND id != %d";
|
||||
$query_params[] = $doctor_id;
|
||||
}
|
||||
|
||||
$existing_doctor = $wpdb->get_var( $wpdb->prepare( $existing_query, $query_params ) );
|
||||
|
||||
if ( $existing_doctor ) {
|
||||
$errors[] = 'A doctor with this ID already exists';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate email format and uniqueness
|
||||
if ( ! empty( $doctor_data['user_email'] ) ) {
|
||||
if ( ! is_email( $doctor_data['user_email'] ) ) {
|
||||
$errors[] = 'Invalid email format';
|
||||
} else {
|
||||
$existing_email = email_exists( $doctor_data['user_email'] );
|
||||
if ( $existing_email && ( ! $doctor_id || $existing_email != $doctor_id ) ) {
|
||||
$errors[] = 'Email already exists';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate mobile number format
|
||||
if ( ! empty( $doctor_data['mobile_number'] ) ) {
|
||||
if ( ! preg_match( '/^[+]?[0-9\s\-\(\)]{7,20}$/', $doctor_data['mobile_number'] ) ) {
|
||||
$errors[] = 'Invalid mobile number format';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate specialties
|
||||
if ( ! empty( $doctor_data['specialties'] ) ) {
|
||||
$specialties = is_array( $doctor_data['specialties'] ) ?
|
||||
$doctor_data['specialties'] :
|
||||
json_decode( $doctor_data['specialties'], true );
|
||||
|
||||
if ( is_array( $specialties ) ) {
|
||||
$valid_specialties = self::get_valid_specialties();
|
||||
foreach ( $specialties as $specialty ) {
|
||||
if ( ! in_array( $specialty, $valid_specialties ) ) {
|
||||
$errors[] = "Invalid specialty: {$specialty}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate license number format (if provided)
|
||||
if ( ! empty( $doctor_data['license_number'] ) ) {
|
||||
if ( ! preg_match( '/^[A-Z0-9\-]{5,20}$/', $doctor_data['license_number'] ) ) {
|
||||
$errors[] = 'Invalid license number format';
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $errors ) ) {
|
||||
return new \WP_Error(
|
||||
'doctor_business_validation_failed',
|
||||
'Doctor business validation failed',
|
||||
array(
|
||||
'status' => 400,
|
||||
'errors' => $errors
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup doctor defaults after creation
|
||||
*
|
||||
* @param int $doctor_id Doctor ID
|
||||
* @param array $doctor_data Doctor data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function setup_doctor_defaults( $doctor_id, $doctor_data ) {
|
||||
// Setup default schedule
|
||||
self::setup_default_schedule( $doctor_id );
|
||||
|
||||
// Initialize preferences
|
||||
self::setup_default_preferences( $doctor_id );
|
||||
|
||||
// Create service mappings if specialties provided
|
||||
if ( ! empty( $doctor_data['specialties'] ) ) {
|
||||
self::create_default_services( $doctor_id, $doctor_data['specialties'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Associate doctor with clinic
|
||||
*
|
||||
* @param int $doctor_id Doctor ID
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function associate_doctor_with_clinic( $doctor_id, $clinic_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->insert(
|
||||
$wpdb->prefix . 'kc_doctor_clinic_mappings',
|
||||
array(
|
||||
'doctor_id' => $doctor_id,
|
||||
'clinic_id' => $clinic_id,
|
||||
'created_at' => current_time( 'mysql' )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid medical specialties
|
||||
*
|
||||
* @return array Valid specialties
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_valid_specialties() {
|
||||
return array(
|
||||
'general_medicine', 'cardiology', 'dermatology', 'endocrinology',
|
||||
'gastroenterology', 'gynecology', 'neurology', 'oncology',
|
||||
'ophthalmology', 'orthopedics', 'otolaryngology', 'pediatrics',
|
||||
'psychiatry', 'pulmonology', 'radiology', 'urology', 'surgery',
|
||||
'anesthesiology', 'pathology', 'emergency_medicine', 'family_medicine'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doctor statistics
|
||||
*
|
||||
* @param int $doctor_id Doctor ID
|
||||
* @return array Statistics
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_doctor_statistics( $doctor_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$stats = array();
|
||||
|
||||
// Total patients
|
||||
$stats['total_patients'] = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT patient_id) FROM {$wpdb->prefix}kc_appointments WHERE doctor_id = %d",
|
||||
$doctor_id
|
||||
)
|
||||
);
|
||||
|
||||
// Total appointments
|
||||
$stats['total_appointments'] = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments WHERE doctor_id = %d",
|
||||
$doctor_id
|
||||
)
|
||||
);
|
||||
|
||||
// This month appointments
|
||||
$stats['this_month_appointments'] = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}kc_appointments
|
||||
WHERE doctor_id = %d AND MONTH(appointment_start_date) = MONTH(CURDATE())
|
||||
AND YEAR(appointment_start_date) = YEAR(CURDATE())",
|
||||
$doctor_id
|
||||
)
|
||||
);
|
||||
|
||||
// Revenue (if bills are linked to appointments)
|
||||
$stats['total_revenue'] = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COALESCE(SUM(b.total_amount), 0)
|
||||
FROM {$wpdb->prefix}kc_bills b
|
||||
JOIN {$wpdb->prefix}kc_appointments a ON b.appointment_id = a.id
|
||||
WHERE a.doctor_id = %d AND b.status = 'paid'",
|
||||
$doctor_id
|
||||
)
|
||||
);
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
// Additional helper methods would be implemented here...
|
||||
private static function get_doctor_clinics( $doctor_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT c.id, c.name, c.address, c.city
|
||||
FROM {$wpdb->prefix}kc_doctor_clinic_mappings dcm
|
||||
JOIN {$wpdb->prefix}kc_clinics c ON dcm.clinic_id = c.id
|
||||
WHERE dcm.doctor_id = %d",
|
||||
$doctor_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_doctor_specialties( $doctor_id ) {
|
||||
$doctor = Doctor::get_by_id( $doctor_id );
|
||||
if ( $doctor && ! empty( $doctor['specialties'] ) ) {
|
||||
return is_array( $doctor['specialties'] ) ?
|
||||
$doctor['specialties'] :
|
||||
json_decode( $doctor['specialties'], true );
|
||||
}
|
||||
return array();
|
||||
}
|
||||
|
||||
private static function get_doctor_schedule( $doctor_id ) {
|
||||
return get_option( "kivicare_doctor_{$doctor_id}_schedule", array() );
|
||||
}
|
||||
|
||||
private static function get_recent_appointments( $doctor_id, $limit ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT a.*, p.first_name, p.last_name, c.name as clinic_name
|
||||
FROM {$wpdb->prefix}kc_appointments a
|
||||
LEFT JOIN {$wpdb->prefix}kc_patients p ON a.patient_id = p.id
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON a.clinic_id = c.id
|
||||
WHERE a.doctor_id = %d
|
||||
ORDER BY a.appointment_start_date DESC
|
||||
LIMIT %d",
|
||||
$doctor_id, $limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_doctor_qualifications( $doctor_id ) {
|
||||
return get_option( "kivicare_doctor_{$doctor_id}_qualifications", array() );
|
||||
}
|
||||
|
||||
private static function get_doctor_availability( $doctor_id ) {
|
||||
// This would calculate current availability based on schedule and appointments
|
||||
return array(
|
||||
'today' => self::get_today_availability( $doctor_id ),
|
||||
'this_week' => self::get_week_availability( $doctor_id ),
|
||||
'next_available' => self::get_next_available_slot( $doctor_id )
|
||||
);
|
||||
}
|
||||
|
||||
// Event handlers and additional methods...
|
||||
public static function on_doctor_created( $doctor_id, $doctor_data ) {
|
||||
error_log( "KiviCare: New doctor created - ID: {$doctor_id}, Name: " . ( $doctor_data['first_name'] ?? 'Unknown' ) );
|
||||
}
|
||||
|
||||
public static function on_doctor_updated( $doctor_id, $doctor_data ) {
|
||||
error_log( "KiviCare: Doctor updated - ID: {$doctor_id}" );
|
||||
wp_cache_delete( "doctor_{$doctor_id}", 'kivicare' );
|
||||
}
|
||||
|
||||
public static function on_doctor_deleted( $doctor_id ) {
|
||||
// Clean up related data
|
||||
delete_option( "kivicare_doctor_{$doctor_id}_schedule" );
|
||||
delete_option( "kivicare_doctor_{$doctor_id}_preferences" );
|
||||
delete_option( "kivicare_doctor_{$doctor_id}_qualifications" );
|
||||
|
||||
wp_cache_delete( "doctor_{$doctor_id}", 'kivicare' );
|
||||
error_log( "KiviCare: Doctor deleted - ID: {$doctor_id}" );
|
||||
}
|
||||
|
||||
// Placeholder methods for additional functionality
|
||||
private static function setup_default_schedule( $doctor_id ) {
|
||||
$default_schedule = array(
|
||||
'monday' => array( 'start_time' => '09:00', 'end_time' => '17:00', 'break_start' => '12:00', 'break_end' => '13:00' ),
|
||||
'tuesday' => array( 'start_time' => '09:00', 'end_time' => '17:00', 'break_start' => '12:00', 'break_end' => '13:00' ),
|
||||
'wednesday' => array( 'start_time' => '09:00', 'end_time' => '17:00', 'break_start' => '12:00', 'break_end' => '13:00' ),
|
||||
'thursday' => array( 'start_time' => '09:00', 'end_time' => '17:00', 'break_start' => '12:00', 'break_end' => '13:00' ),
|
||||
'friday' => array( 'start_time' => '09:00', 'end_time' => '17:00', 'break_start' => '12:00', 'break_end' => '13:00' ),
|
||||
'saturday' => array( 'start_time' => '09:00', 'end_time' => '13:00' ),
|
||||
'sunday' => array( 'closed' => true )
|
||||
);
|
||||
|
||||
update_option( "kivicare_doctor_{$doctor_id}_schedule", $default_schedule );
|
||||
}
|
||||
|
||||
private static function setup_default_preferences( $doctor_id ) {
|
||||
$default_preferences = array(
|
||||
'appointment_duration' => 30,
|
||||
'buffer_time' => 5,
|
||||
'max_appointments_per_day' => 20,
|
||||
'email_notifications' => true,
|
||||
'sms_notifications' => false,
|
||||
'auto_confirm_appointments' => false
|
||||
);
|
||||
|
||||
update_option( "kivicare_doctor_{$doctor_id}_preferences", $default_preferences );
|
||||
}
|
||||
|
||||
private static function create_default_services( $doctor_id, $specialties ) {
|
||||
// This would create default services based on doctor specialties
|
||||
// Implementation would depend on the services structure
|
||||
}
|
||||
|
||||
private static function handle_specialty_changes( $doctor_id, $current_data, $new_data ) {
|
||||
// Handle when doctor specialties change
|
||||
if ( isset( $new_data['specialties'] ) ) {
|
||||
$old_specialties = isset( $current_data['specialties'] ) ?
|
||||
( is_array( $current_data['specialties'] ) ? $current_data['specialties'] : json_decode( $current_data['specialties'], true ) ) : array();
|
||||
|
||||
$new_specialties = is_array( $new_data['specialties'] ) ?
|
||||
$new_data['specialties'] :
|
||||
json_decode( $new_data['specialties'], true );
|
||||
|
||||
if ( $old_specialties !== $new_specialties ) {
|
||||
do_action( 'kivicare_doctor_specialties_changed', $doctor_id, $old_specialties, $new_specialties );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function update_clinic_associations( $doctor_id, $clinic_ids ) {
|
||||
global $wpdb;
|
||||
|
||||
// Remove existing associations
|
||||
$wpdb->delete(
|
||||
$wpdb->prefix . 'kc_doctor_clinic_mappings',
|
||||
array( 'doctor_id' => $doctor_id )
|
||||
);
|
||||
|
||||
// Add new associations
|
||||
foreach ( $clinic_ids as $clinic_id ) {
|
||||
$wpdb->insert(
|
||||
$wpdb->prefix . 'kc_doctor_clinic_mappings',
|
||||
array(
|
||||
'doctor_id' => $doctor_id,
|
||||
'clinic_id' => $clinic_id,
|
||||
'created_at' => current_time( 'mysql' )
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static function send_doctor_welcome_email( $user_id, $username, $password ) {
|
||||
// Implementation for sending welcome email with credentials
|
||||
$user = get_user_by( 'id', $user_id );
|
||||
if ( $user ) {
|
||||
wp_new_user_notification( $user_id, null, 'both' );
|
||||
}
|
||||
}
|
||||
|
||||
// Additional placeholder methods for dashboard functionality
|
||||
private static function get_doctor_daily_schedule( $doctor_id, $date ) {
|
||||
// Get appointments for specific date
|
||||
global $wpdb;
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_appointments
|
||||
WHERE doctor_id = %d AND DATE(appointment_start_date) = %s
|
||||
ORDER BY appointment_start_time",
|
||||
$doctor_id, $date
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_comprehensive_statistics( $doctor_id ) {
|
||||
return self::get_doctor_statistics( $doctor_id );
|
||||
}
|
||||
|
||||
private static function get_recent_patients( $doctor_id, $limit ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT DISTINCT p.*, MAX(a.appointment_start_date) as last_visit
|
||||
FROM {$wpdb->prefix}kc_patients p
|
||||
JOIN {$wpdb->prefix}kc_appointments a ON p.id = a.patient_id
|
||||
WHERE a.doctor_id = %d
|
||||
GROUP BY p.id
|
||||
ORDER BY last_visit DESC
|
||||
LIMIT %d",
|
||||
$doctor_id, $limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_upcoming_appointments( $doctor_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT a.*, p.first_name, p.last_name
|
||||
FROM {$wpdb->prefix}kc_appointments a
|
||||
JOIN {$wpdb->prefix}kc_patients p ON a.patient_id = p.id
|
||||
WHERE a.doctor_id = %d AND a.appointment_start_date >= CURDATE()
|
||||
ORDER BY a.appointment_start_date, a.appointment_start_time
|
||||
LIMIT 10",
|
||||
$doctor_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_performance_metrics( $doctor_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$metrics = array();
|
||||
|
||||
// Completion rate
|
||||
$completion_data = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) as cancelled
|
||||
FROM {$wpdb->prefix}kc_appointments
|
||||
WHERE doctor_id = %d AND appointment_start_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)",
|
||||
$doctor_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( $completion_data && $completion_data['total'] > 0 ) {
|
||||
$metrics['completion_rate'] = round( ( $completion_data['completed'] / $completion_data['total'] ) * 100, 1 );
|
||||
$metrics['cancellation_rate'] = round( ( $completion_data['cancelled'] / $completion_data['total'] ) * 100, 1 );
|
||||
} else {
|
||||
$metrics['completion_rate'] = 0;
|
||||
$metrics['cancellation_rate'] = 0;
|
||||
}
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
private static function get_revenue_data( $doctor_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT DATE_FORMAT(b.created_at, '%%Y-%%m') as month, SUM(b.total_amount) as revenue
|
||||
FROM {$wpdb->prefix}kc_bills b
|
||||
JOIN {$wpdb->prefix}kc_appointments a ON b.appointment_id = a.id
|
||||
WHERE a.doctor_id = %d AND b.status = 'paid'
|
||||
GROUP BY DATE_FORMAT(b.created_at, '%%Y-%%m')
|
||||
ORDER BY month DESC
|
||||
LIMIT 12",
|
||||
$doctor_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_today_availability( $doctor_id ) {
|
||||
// Calculate available slots for today
|
||||
return array();
|
||||
}
|
||||
|
||||
private static function get_week_availability( $doctor_id ) {
|
||||
// Calculate available slots for this week
|
||||
return array();
|
||||
}
|
||||
|
||||
private static function get_next_available_slot( $doctor_id ) {
|
||||
// Find next available appointment slot
|
||||
return null;
|
||||
}
|
||||
}
|
||||
891
src/includes/services/database/class-encounter-service.php
Normal file
891
src/includes/services/database/class-encounter-service.php
Normal file
@@ -0,0 +1,891 @@
|
||||
<?php
|
||||
/**
|
||||
* Encounter Database Service
|
||||
*
|
||||
* Handles advanced encounter data operations and business logic
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Services\Database
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Services\Database;
|
||||
|
||||
use KiviCare_API\Models\Encounter;
|
||||
use KiviCare_API\Services\Permission_Service;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Encounter_Service
|
||||
*
|
||||
* Advanced database service for encounter management with business logic
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Encounter_Service {
|
||||
|
||||
/**
|
||||
* Initialize the service
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function init() {
|
||||
// Hook into WordPress actions
|
||||
add_action( 'kivicare_encounter_created', array( self::class, 'on_encounter_created' ), 10, 2 );
|
||||
add_action( 'kivicare_encounter_updated', array( self::class, 'on_encounter_updated' ), 10, 2 );
|
||||
add_action( 'kivicare_encounter_deleted', array( self::class, 'on_encounter_deleted' ), 10, 1 );
|
||||
add_action( 'kivicare_encounter_finalized', array( self::class, 'on_encounter_finalized' ), 10, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create encounter with advanced business logic
|
||||
*
|
||||
* @param array $encounter_data Encounter data
|
||||
* @param int $user_id Creating user ID
|
||||
* @return array|WP_Error Encounter data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function create_encounter( $encounter_data, $user_id = null ) {
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_manage_encounters( get_current_user_id(), $encounter_data['clinic_id'] ?? 0 ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'You do not have permission to create encounters',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced validation
|
||||
$validation = self::validate_encounter_business_rules( $encounter_data );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
$encounter_data['created_by'] = $user_id ?: get_current_user_id();
|
||||
$encounter_data['created_at'] = current_time( 'mysql' );
|
||||
$encounter_data['status'] = 'draft'; // Default status
|
||||
|
||||
// Generate encounter number if not provided
|
||||
if ( empty( $encounter_data['encounter_number'] ) ) {
|
||||
$encounter_data['encounter_number'] = self::generate_encounter_number( $encounter_data['clinic_id'] );
|
||||
}
|
||||
|
||||
// Set encounter date if not provided
|
||||
if ( empty( $encounter_data['encounter_date'] ) ) {
|
||||
$encounter_data['encounter_date'] = current_time( 'mysql' );
|
||||
}
|
||||
|
||||
// Create encounter
|
||||
$encounter_id = Encounter::create( $encounter_data );
|
||||
|
||||
if ( is_wp_error( $encounter_id ) ) {
|
||||
return $encounter_id;
|
||||
}
|
||||
|
||||
// Post-creation tasks
|
||||
self::setup_encounter_defaults( $encounter_id, $encounter_data );
|
||||
|
||||
// Auto-link to appointment if provided
|
||||
if ( ! empty( $encounter_data['appointment_id'] ) ) {
|
||||
self::link_encounter_to_appointment( $encounter_id, $encounter_data['appointment_id'] );
|
||||
}
|
||||
|
||||
// Trigger action
|
||||
do_action( 'kivicare_encounter_created', $encounter_id, $encounter_data );
|
||||
|
||||
// Return full encounter data
|
||||
return self::get_encounter_with_metadata( $encounter_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Update encounter with business logic
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @param array $encounter_data Updated data
|
||||
* @return array|WP_Error Updated encounter data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function update_encounter( $encounter_id, $encounter_data ) {
|
||||
// Get current encounter data
|
||||
$current_encounter = Encounter::get_by_id( $encounter_id );
|
||||
if ( ! $current_encounter ) {
|
||||
return new \WP_Error(
|
||||
'encounter_not_found',
|
||||
'Encounter not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_manage_encounters( get_current_user_id(), $current_encounter['clinic_id'] ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'You do not have permission to update this encounter',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check if encounter is finalized
|
||||
if ( $current_encounter['status'] === 'finalized' ) {
|
||||
return new \WP_Error(
|
||||
'encounter_finalized',
|
||||
'Cannot update a finalized encounter',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced validation
|
||||
$validation = self::validate_encounter_business_rules( $encounter_data, $encounter_id );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
// Add update metadata
|
||||
$encounter_data['updated_by'] = get_current_user_id();
|
||||
$encounter_data['updated_at'] = current_time( 'mysql' );
|
||||
|
||||
// Update encounter
|
||||
$result = Encounter::update( $encounter_id, $encounter_data );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Handle status changes
|
||||
self::handle_status_changes( $encounter_id, $current_encounter, $encounter_data );
|
||||
|
||||
// Handle SOAP notes updates
|
||||
if ( isset( $encounter_data['soap_notes'] ) ) {
|
||||
self::update_soap_notes( $encounter_id, $encounter_data['soap_notes'] );
|
||||
}
|
||||
|
||||
// Handle vital signs updates
|
||||
if ( isset( $encounter_data['vital_signs'] ) ) {
|
||||
self::update_vital_signs( $encounter_id, $encounter_data['vital_signs'] );
|
||||
}
|
||||
|
||||
// Trigger action
|
||||
do_action( 'kivicare_encounter_updated', $encounter_id, $encounter_data );
|
||||
|
||||
// Return updated encounter data
|
||||
return self::get_encounter_with_metadata( $encounter_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize encounter
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @param array $final_data Final data
|
||||
* @return array|WP_Error Updated encounter data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function finalize_encounter( $encounter_id, $final_data = array() ) {
|
||||
$encounter = Encounter::get_by_id( $encounter_id );
|
||||
if ( ! $encounter ) {
|
||||
return new \WP_Error(
|
||||
'encounter_not_found',
|
||||
'Encounter not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_manage_encounters( get_current_user_id(), $encounter['clinic_id'] ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'You do not have permission to finalize this encounter',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check if already finalized
|
||||
if ( $encounter['status'] === 'finalized' ) {
|
||||
return new \WP_Error(
|
||||
'already_finalized',
|
||||
'Encounter is already finalized',
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required data for finalization
|
||||
$validation = self::validate_finalization_requirements( $encounter_id );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
// Update encounter status
|
||||
$update_data = array_merge( $final_data, array(
|
||||
'status' => 'finalized',
|
||||
'finalized_by' => get_current_user_id(),
|
||||
'finalized_at' => current_time( 'mysql' ),
|
||||
'updated_at' => current_time( 'mysql' )
|
||||
));
|
||||
|
||||
$result = Encounter::update( $encounter_id, $update_data );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Post-finalization tasks
|
||||
self::handle_finalization_tasks( $encounter_id );
|
||||
|
||||
// Trigger action
|
||||
do_action( 'kivicare_encounter_finalized', $encounter_id );
|
||||
|
||||
return self::get_encounter_with_metadata( $encounter_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encounter with enhanced metadata
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @return array|WP_Error Encounter data with metadata or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_encounter_with_metadata( $encounter_id ) {
|
||||
$encounter = Encounter::get_by_id( $encounter_id );
|
||||
|
||||
if ( ! $encounter ) {
|
||||
return new \WP_Error(
|
||||
'encounter_not_found',
|
||||
'Encounter not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_view_encounter( get_current_user_id(), $encounter_id ) ) {
|
||||
return new \WP_Error(
|
||||
'access_denied',
|
||||
'You do not have access to this encounter',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Add enhanced metadata
|
||||
$encounter['patient'] = self::get_encounter_patient( $encounter['patient_id'] );
|
||||
$encounter['doctor'] = self::get_encounter_doctor( $encounter['doctor_id'] );
|
||||
$encounter['clinic'] = self::get_encounter_clinic( $encounter['clinic_id'] );
|
||||
$encounter['appointment'] = self::get_encounter_appointment( $encounter['appointment_id'] ?? null );
|
||||
$encounter['soap_notes'] = self::get_soap_notes( $encounter_id );
|
||||
$encounter['vital_signs'] = self::get_vital_signs( $encounter_id );
|
||||
$encounter['diagnoses'] = self::get_encounter_diagnoses( $encounter_id );
|
||||
$encounter['prescriptions'] = self::get_encounter_prescriptions( $encounter_id );
|
||||
$encounter['attachments'] = self::get_encounter_attachments( $encounter_id );
|
||||
$encounter['bills'] = self::get_encounter_bills( $encounter_id );
|
||||
|
||||
return $encounter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search encounters with advanced criteria
|
||||
*
|
||||
* @param array $filters Search filters
|
||||
* @return array Search results
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function search_encounters( $filters = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$accessible_clinic_ids = Permission_Service::get_accessible_clinic_ids( $user_id );
|
||||
|
||||
if ( empty( $accessible_clinic_ids ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
// Build search query
|
||||
$where_clauses = array( "e.clinic_id IN (" . implode( ',', $accessible_clinic_ids ) . ")" );
|
||||
$where_values = array();
|
||||
|
||||
// Date range filter
|
||||
if ( ! empty( $filters['start_date'] ) ) {
|
||||
$where_clauses[] = "DATE(e.encounter_date) >= %s";
|
||||
$where_values[] = $filters['start_date'];
|
||||
}
|
||||
|
||||
if ( ! empty( $filters['end_date'] ) ) {
|
||||
$where_clauses[] = "DATE(e.encounter_date) <= %s";
|
||||
$where_values[] = $filters['end_date'];
|
||||
}
|
||||
|
||||
// Doctor filter
|
||||
if ( ! empty( $filters['doctor_id'] ) ) {
|
||||
$where_clauses[] = "e.doctor_id = %d";
|
||||
$where_values[] = $filters['doctor_id'];
|
||||
}
|
||||
|
||||
// Patient filter
|
||||
if ( ! empty( $filters['patient_id'] ) ) {
|
||||
$where_clauses[] = "e.patient_id = %d";
|
||||
$where_values[] = $filters['patient_id'];
|
||||
}
|
||||
|
||||
// Clinic filter
|
||||
if ( ! empty( $filters['clinic_id'] ) && in_array( $filters['clinic_id'], $accessible_clinic_ids ) ) {
|
||||
$where_clauses[] = "e.clinic_id = %d";
|
||||
$where_values[] = $filters['clinic_id'];
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ( ! empty( $filters['status'] ) ) {
|
||||
if ( is_array( $filters['status'] ) ) {
|
||||
$status_placeholders = implode( ',', array_fill( 0, count( $filters['status'] ), '%s' ) );
|
||||
$where_clauses[] = "e.status IN ({$status_placeholders})";
|
||||
$where_values = array_merge( $where_values, $filters['status'] );
|
||||
} else {
|
||||
$where_clauses[] = "e.status = %s";
|
||||
$where_values[] = $filters['status'];
|
||||
}
|
||||
}
|
||||
|
||||
// Search term
|
||||
if ( ! empty( $filters['search'] ) ) {
|
||||
$where_clauses[] = "(p.first_name LIKE %s OR p.last_name LIKE %s OR d.first_name LIKE %s OR d.last_name LIKE %s OR e.encounter_number LIKE %s OR e.chief_complaint LIKE %s)";
|
||||
$search_term = '%' . $wpdb->esc_like( $filters['search'] ) . '%';
|
||||
$where_values = array_merge( $where_values, array_fill( 0, 6, $search_term ) );
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
// Pagination
|
||||
$limit = isset( $filters['limit'] ) ? (int) $filters['limit'] : 20;
|
||||
$offset = isset( $filters['offset'] ) ? (int) $filters['offset'] : 0;
|
||||
|
||||
$query = "SELECT e.*,
|
||||
p.first_name as patient_first_name, p.last_name as patient_last_name,
|
||||
d.first_name as doctor_first_name, d.last_name as doctor_last_name,
|
||||
c.name as clinic_name
|
||||
FROM {$wpdb->prefix}kc_encounters e
|
||||
LEFT JOIN {$wpdb->prefix}kc_patients p ON e.patient_id = p.id
|
||||
LEFT JOIN {$wpdb->prefix}kc_doctors d ON e.doctor_id = d.id
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON e.clinic_id = c.id
|
||||
WHERE {$where_sql}
|
||||
ORDER BY e.encounter_date DESC
|
||||
LIMIT {$limit} OFFSET {$offset}";
|
||||
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$results = $wpdb->get_results( $wpdb->prepare( $query, $where_values ), ARRAY_A );
|
||||
} else {
|
||||
$results = $wpdb->get_results( $query, ARRAY_A );
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
$count_query = "SELECT COUNT(*) FROM {$wpdb->prefix}kc_encounters e
|
||||
LEFT JOIN {$wpdb->prefix}kc_patients p ON e.patient_id = p.id
|
||||
LEFT JOIN {$wpdb->prefix}kc_doctors d ON e.doctor_id = d.id
|
||||
WHERE {$where_sql}";
|
||||
|
||||
if ( ! empty( $where_values ) ) {
|
||||
$total = (int) $wpdb->get_var( $wpdb->prepare( $count_query, $where_values ) );
|
||||
} else {
|
||||
$total = (int) $wpdb->get_var( $count_query );
|
||||
}
|
||||
|
||||
return array(
|
||||
'encounters' => array_map( function( $encounter ) {
|
||||
$encounter['id'] = (int) $encounter['id'];
|
||||
return $encounter;
|
||||
}, $results ),
|
||||
'total' => $total,
|
||||
'has_more' => ( $offset + $limit ) < $total
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient encounter history
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @param int $limit Limit
|
||||
* @return array Encounter history
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_patient_encounter_history( $patient_id, $limit = 10 ) {
|
||||
global $wpdb;
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$accessible_clinic_ids = Permission_Service::get_accessible_clinic_ids( $user_id );
|
||||
|
||||
if ( empty( $accessible_clinic_ids ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$query = "SELECT e.*,
|
||||
d.first_name as doctor_first_name, d.last_name as doctor_last_name,
|
||||
c.name as clinic_name
|
||||
FROM {$wpdb->prefix}kc_encounters e
|
||||
LEFT JOIN {$wpdb->prefix}kc_doctors d ON e.doctor_id = d.id
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON e.clinic_id = c.id
|
||||
WHERE e.patient_id = %d
|
||||
AND e.clinic_id IN (" . implode( ',', $accessible_clinic_ids ) . ")
|
||||
ORDER BY e.encounter_date DESC
|
||||
LIMIT %d";
|
||||
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare( $query, $patient_id, $limit ),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map( function( $encounter ) {
|
||||
$encounter['id'] = (int) $encounter['id'];
|
||||
$encounter['soap_notes'] = self::get_soap_notes( $encounter['id'] );
|
||||
$encounter['diagnoses'] = self::get_encounter_diagnoses( $encounter['id'] );
|
||||
return $encounter;
|
||||
}, $results );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique encounter number
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return string Encounter number
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function generate_encounter_number( $clinic_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$prefix = 'E' . str_pad( $clinic_id, 3, '0', STR_PAD_LEFT ) . date( 'ym' );
|
||||
|
||||
// Get the highest existing encounter number for this clinic and month
|
||||
$max_number = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT MAX(CAST(SUBSTRING(encounter_number, 8) AS UNSIGNED))
|
||||
FROM {$wpdb->prefix}kc_encounters
|
||||
WHERE encounter_number LIKE %s",
|
||||
$prefix . '%'
|
||||
)
|
||||
);
|
||||
|
||||
$next_number = ( $max_number ? $max_number + 1 : 1 );
|
||||
|
||||
return $prefix . str_pad( $next_number, 4, '0', STR_PAD_LEFT );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate encounter business rules
|
||||
*
|
||||
* @param array $encounter_data Encounter data
|
||||
* @param int $encounter_id Encounter ID (for updates)
|
||||
* @return bool|WP_Error True if valid, WP_Error otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_encounter_business_rules( $encounter_data, $encounter_id = null ) {
|
||||
$errors = array();
|
||||
|
||||
// Validate required fields
|
||||
$required_fields = array( 'patient_id', 'doctor_id', 'clinic_id' );
|
||||
|
||||
foreach ( $required_fields as $field ) {
|
||||
if ( empty( $encounter_data[$field] ) ) {
|
||||
$errors[] = "Field {$field} is required";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate patient exists
|
||||
if ( ! empty( $encounter_data['patient_id'] ) ) {
|
||||
global $wpdb;
|
||||
$patient_exists = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$wpdb->prefix}kc_patients WHERE id = %d",
|
||||
$encounter_data['patient_id']
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! $patient_exists ) {
|
||||
$errors[] = 'Invalid patient ID';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate doctor exists
|
||||
if ( ! empty( $encounter_data['doctor_id'] ) ) {
|
||||
global $wpdb;
|
||||
$doctor_exists = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$wpdb->prefix}kc_doctors WHERE id = %d",
|
||||
$encounter_data['doctor_id']
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! $doctor_exists ) {
|
||||
$errors[] = 'Invalid doctor ID';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate clinic exists
|
||||
if ( ! empty( $encounter_data['clinic_id'] ) ) {
|
||||
global $wpdb;
|
||||
$clinic_exists = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$wpdb->prefix}kc_clinics WHERE id = %d",
|
||||
$encounter_data['clinic_id']
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! $clinic_exists ) {
|
||||
$errors[] = 'Invalid clinic ID';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate appointment if provided
|
||||
if ( ! empty( $encounter_data['appointment_id'] ) ) {
|
||||
global $wpdb;
|
||||
$appointment_exists = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$wpdb->prefix}kc_appointments WHERE id = %d",
|
||||
$encounter_data['appointment_id']
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! $appointment_exists ) {
|
||||
$errors[] = 'Invalid appointment ID';
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $errors ) ) {
|
||||
return new \WP_Error(
|
||||
'encounter_business_validation_failed',
|
||||
'Encounter business validation failed',
|
||||
array(
|
||||
'status' => 400,
|
||||
'errors' => $errors
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate finalization requirements
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @return bool|WP_Error True if valid, WP_Error otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_finalization_requirements( $encounter_id ) {
|
||||
$encounter = Encounter::get_by_id( $encounter_id );
|
||||
$errors = array();
|
||||
|
||||
// Check if chief complaint is provided
|
||||
if ( empty( $encounter['chief_complaint'] ) ) {
|
||||
$errors[] = 'Chief complaint is required for finalization';
|
||||
}
|
||||
|
||||
// Check if at least one SOAP note section is filled
|
||||
$soap_notes = self::get_soap_notes( $encounter_id );
|
||||
if ( empty( $soap_notes['subjective'] ) && empty( $soap_notes['objective'] ) &&
|
||||
empty( $soap_notes['assessment'] ) && empty( $soap_notes['plan'] ) ) {
|
||||
$errors[] = 'At least one SOAP note section must be completed for finalization';
|
||||
}
|
||||
|
||||
if ( ! empty( $errors ) ) {
|
||||
return new \WP_Error(
|
||||
'encounter_finalization_validation_failed',
|
||||
'Encounter finalization validation failed',
|
||||
array(
|
||||
'status' => 400,
|
||||
'errors' => $errors
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup encounter defaults after creation
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @param array $encounter_data Encounter data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function setup_encounter_defaults( $encounter_id, $encounter_data ) {
|
||||
// Initialize SOAP notes structure
|
||||
self::initialize_soap_notes( $encounter_id );
|
||||
|
||||
// Initialize vital signs structure
|
||||
self::initialize_vital_signs( $encounter_id );
|
||||
|
||||
// Setup encounter preferences
|
||||
self::setup_encounter_preferences( $encounter_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Link encounter to appointment
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @param int $appointment_id Appointment ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function link_encounter_to_appointment( $encounter_id, $appointment_id ) {
|
||||
global $wpdb;
|
||||
|
||||
// Update appointment with encounter reference
|
||||
$wpdb->update(
|
||||
$wpdb->prefix . 'kc_appointments',
|
||||
array( 'encounter_id' => $encounter_id ),
|
||||
array( 'id' => $appointment_id ),
|
||||
array( '%d' ),
|
||||
array( '%d' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle status changes
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @param array $current_encounter Current encounter data
|
||||
* @param array $new_data New data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function handle_status_changes( $encounter_id, $current_encounter, $new_data ) {
|
||||
if ( isset( $new_data['status'] ) && $new_data['status'] != $current_encounter['status'] ) {
|
||||
$status_change = array(
|
||||
'encounter_id' => $encounter_id,
|
||||
'from_status' => $current_encounter['status'],
|
||||
'to_status' => $new_data['status'],
|
||||
'changed_by' => get_current_user_id(),
|
||||
'changed_at' => current_time( 'mysql' )
|
||||
);
|
||||
|
||||
do_action( 'kivicare_encounter_status_changed', $status_change );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle finalization tasks
|
||||
*
|
||||
* @param int $encounter_id Encounter ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function handle_finalization_tasks( $encounter_id ) {
|
||||
// Generate encounter summary
|
||||
self::generate_encounter_summary( $encounter_id );
|
||||
|
||||
// Auto-create follow-up reminders if needed
|
||||
self::create_follow_up_reminders( $encounter_id );
|
||||
|
||||
// Update patient medical history
|
||||
self::update_patient_medical_history( $encounter_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper methods for encounter data management
|
||||
*/
|
||||
|
||||
private static function initialize_soap_notes( $encounter_id ) {
|
||||
$default_soap = array(
|
||||
'subjective' => '',
|
||||
'objective' => '',
|
||||
'assessment' => '',
|
||||
'plan' => ''
|
||||
);
|
||||
|
||||
update_option( "kivicare_encounter_{$encounter_id}_soap_notes", $default_soap );
|
||||
}
|
||||
|
||||
private static function initialize_vital_signs( $encounter_id ) {
|
||||
$default_vitals = array(
|
||||
'temperature' => '',
|
||||
'blood_pressure_systolic' => '',
|
||||
'blood_pressure_diastolic' => '',
|
||||
'heart_rate' => '',
|
||||
'respiratory_rate' => '',
|
||||
'oxygen_saturation' => '',
|
||||
'weight' => '',
|
||||
'height' => '',
|
||||
'bmi' => ''
|
||||
);
|
||||
|
||||
update_option( "kivicare_encounter_{$encounter_id}_vital_signs", $default_vitals );
|
||||
}
|
||||
|
||||
private static function setup_encounter_preferences( $encounter_id ) {
|
||||
$default_preferences = array(
|
||||
'auto_save' => true,
|
||||
'show_patient_history' => true,
|
||||
'template_type' => 'standard'
|
||||
);
|
||||
|
||||
update_option( "kivicare_encounter_{$encounter_id}_preferences", $default_preferences );
|
||||
}
|
||||
|
||||
private static function update_soap_notes( $encounter_id, $soap_notes ) {
|
||||
update_option( "kivicare_encounter_{$encounter_id}_soap_notes", $soap_notes );
|
||||
}
|
||||
|
||||
private static function update_vital_signs( $encounter_id, $vital_signs ) {
|
||||
// Calculate BMI if height and weight are provided
|
||||
if ( ! empty( $vital_signs['height'] ) && ! empty( $vital_signs['weight'] ) ) {
|
||||
$height_m = $vital_signs['height'] / 100; // Convert cm to meters
|
||||
$vital_signs['bmi'] = round( $vital_signs['weight'] / ( $height_m * $height_m ), 2 );
|
||||
}
|
||||
|
||||
update_option( "kivicare_encounter_{$encounter_id}_vital_signs", $vital_signs );
|
||||
}
|
||||
|
||||
private static function get_soap_notes( $encounter_id ) {
|
||||
return get_option( "kivicare_encounter_{$encounter_id}_soap_notes", array() );
|
||||
}
|
||||
|
||||
private static function get_vital_signs( $encounter_id ) {
|
||||
return get_option( "kivicare_encounter_{$encounter_id}_vital_signs", array() );
|
||||
}
|
||||
|
||||
private static function get_encounter_patient( $patient_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, first_name, last_name, user_email, contact_no, dob, gender FROM {$wpdb->prefix}kc_patients WHERE id = %d",
|
||||
$patient_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_encounter_doctor( $doctor_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, first_name, last_name, user_email, mobile_number, specialties FROM {$wpdb->prefix}kc_doctors WHERE id = %d",
|
||||
$doctor_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_encounter_clinic( $clinic_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, name, address, city, telephone_no FROM {$wpdb->prefix}kc_clinics WHERE id = %d",
|
||||
$clinic_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_encounter_appointment( $appointment_id ) {
|
||||
if ( ! $appointment_id ) return null;
|
||||
|
||||
global $wpdb;
|
||||
return $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, appointment_number, appointment_start_date, appointment_start_time, status FROM {$wpdb->prefix}kc_appointments WHERE id = %d",
|
||||
$appointment_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_encounter_diagnoses( $encounter_id ) {
|
||||
return get_option( "kivicare_encounter_{$encounter_id}_diagnoses", array() );
|
||||
}
|
||||
|
||||
private static function get_encounter_prescriptions( $encounter_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_prescriptions WHERE encounter_id = %d ORDER BY created_at DESC",
|
||||
$encounter_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_encounter_attachments( $encounter_id ) {
|
||||
return get_option( "kivicare_encounter_{$encounter_id}_attachments", array() );
|
||||
}
|
||||
|
||||
private static function get_encounter_bills( $encounter_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_bills WHERE encounter_id = %d ORDER BY created_at DESC",
|
||||
$encounter_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function generate_encounter_summary( $encounter_id ) {
|
||||
$encounter = Encounter::get_by_id( $encounter_id );
|
||||
$soap_notes = self::get_soap_notes( $encounter_id );
|
||||
|
||||
$summary = array(
|
||||
'encounter_id' => $encounter_id,
|
||||
'chief_complaint' => $encounter['chief_complaint'],
|
||||
'key_findings' => $soap_notes['objective'] ?? '',
|
||||
'diagnosis' => $soap_notes['assessment'] ?? '',
|
||||
'treatment_plan' => $soap_notes['plan'] ?? '',
|
||||
'generated_at' => current_time( 'mysql' )
|
||||
);
|
||||
|
||||
update_option( "kivicare_encounter_{$encounter_id}_summary", $summary );
|
||||
}
|
||||
|
||||
private static function create_follow_up_reminders( $encounter_id ) {
|
||||
// This would create follow-up reminders based on the treatment plan
|
||||
// Implementation depends on reminder system
|
||||
}
|
||||
|
||||
private static function update_patient_medical_history( $encounter_id ) {
|
||||
$encounter = Encounter::get_by_id( $encounter_id );
|
||||
$patient_id = $encounter['patient_id'];
|
||||
|
||||
$medical_history = get_option( "kivicare_patient_{$patient_id}_medical_history", array() );
|
||||
|
||||
// Add this encounter to patient history
|
||||
if ( ! isset( $medical_history['encounters'] ) ) {
|
||||
$medical_history['encounters'] = array();
|
||||
}
|
||||
|
||||
$medical_history['encounters'][] = array(
|
||||
'encounter_id' => $encounter_id,
|
||||
'date' => $encounter['encounter_date'],
|
||||
'chief_complaint' => $encounter['chief_complaint'],
|
||||
'doctor_id' => $encounter['doctor_id'],
|
||||
'clinic_id' => $encounter['clinic_id']
|
||||
);
|
||||
|
||||
update_option( "kivicare_patient_{$patient_id}_medical_history", $medical_history );
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handlers
|
||||
*/
|
||||
public static function on_encounter_created( $encounter_id, $encounter_data ) {
|
||||
error_log( "KiviCare: New encounter created - ID: {$encounter_id}, Patient: " . ( $encounter_data['patient_id'] ?? 'Unknown' ) );
|
||||
}
|
||||
|
||||
public static function on_encounter_updated( $encounter_id, $encounter_data ) {
|
||||
error_log( "KiviCare: Encounter updated - ID: {$encounter_id}" );
|
||||
wp_cache_delete( "encounter_{$encounter_id}", 'kivicare' );
|
||||
}
|
||||
|
||||
public static function on_encounter_deleted( $encounter_id ) {
|
||||
// Clean up related data
|
||||
delete_option( "kivicare_encounter_{$encounter_id}_soap_notes" );
|
||||
delete_option( "kivicare_encounter_{$encounter_id}_vital_signs" );
|
||||
delete_option( "kivicare_encounter_{$encounter_id}_preferences" );
|
||||
delete_option( "kivicare_encounter_{$encounter_id}_diagnoses" );
|
||||
delete_option( "kivicare_encounter_{$encounter_id}_attachments" );
|
||||
delete_option( "kivicare_encounter_{$encounter_id}_summary" );
|
||||
|
||||
wp_cache_delete( "encounter_{$encounter_id}", 'kivicare' );
|
||||
error_log( "KiviCare: Encounter deleted - ID: {$encounter_id}" );
|
||||
}
|
||||
|
||||
public static function on_encounter_finalized( $encounter_id ) {
|
||||
error_log( "KiviCare: Encounter finalized - ID: {$encounter_id}" );
|
||||
wp_cache_delete( "encounter_{$encounter_id}", 'kivicare' );
|
||||
}
|
||||
}
|
||||
743
src/includes/services/database/class-patient-service.php
Normal file
743
src/includes/services/database/class-patient-service.php
Normal file
@@ -0,0 +1,743 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Patient Database Service
|
||||
*
|
||||
* Handles advanced patient data operations and business logic
|
||||
*
|
||||
* @package KiviCare_API
|
||||
* @subpackage Services\Database
|
||||
* @version 1.0.0
|
||||
* @author Descomplicar® <dev@descomplicar.pt>
|
||||
* @link https://descomplicar.pt
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
namespace KiviCare_API\Services\Database;
|
||||
|
||||
use KiviCare_API\Models\Patient;
|
||||
use KiviCare_API\Services\Permission_Service;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Patient_Service
|
||||
*
|
||||
* Advanced database service for patient management with business logic
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Patient_Service {
|
||||
|
||||
/**
|
||||
* Initialize the service
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function init() {
|
||||
// Hook into WordPress actions
|
||||
add_action( 'kivicare_patient_created', array( self::class, 'on_patient_created' ), 10, 2 );
|
||||
add_action( 'kivicare_patient_updated', array( self::class, 'on_patient_updated' ), 10, 2 );
|
||||
add_action( 'kivicare_patient_deleted', array( self::class, 'on_patient_deleted' ), 10, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create patient with advanced business logic
|
||||
*
|
||||
* @param array $patient_data Patient data
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @param int $user_id Creating user ID
|
||||
* @return array|WP_Error Patient data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function create_patient( $patient_data, $clinic_id, $user_id = null ) {
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_access_clinic( get_current_user_id(), $clinic_id ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'You do not have permission to create patients in this clinic',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced validation
|
||||
$validation = self::validate_patient_business_rules( $patient_data, $clinic_id );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
$patient_data['clinic_id'] = $clinic_id;
|
||||
$patient_data['created_by'] = $user_id ?: get_current_user_id();
|
||||
$patient_data['created_at'] = current_time( 'mysql' );
|
||||
|
||||
// Generate patient ID if not provided
|
||||
if ( empty( $patient_data['patient_id'] ) ) {
|
||||
$patient_data['patient_id'] = self::generate_patient_id( $clinic_id );
|
||||
}
|
||||
|
||||
// Create patient
|
||||
$patient_id = Patient::create( $patient_data );
|
||||
|
||||
if ( is_wp_error( $patient_id ) ) {
|
||||
return $patient_id;
|
||||
}
|
||||
|
||||
// Post-creation tasks
|
||||
self::setup_patient_defaults( $patient_id, $patient_data );
|
||||
|
||||
// Trigger action
|
||||
do_action( 'kivicare_patient_created', $patient_id, $patient_data );
|
||||
|
||||
// Return full patient data
|
||||
return self::get_patient_with_metadata( $patient_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Update patient with business logic
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @param array $patient_data Updated data
|
||||
* @return array|WP_Error Updated patient data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function update_patient( $patient_id, $patient_data ) {
|
||||
// Get current patient data
|
||||
$current_patient = Patient::get_by_id( $patient_id );
|
||||
if ( ! $current_patient ) {
|
||||
return new \WP_Error(
|
||||
'patient_not_found',
|
||||
'Patient not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_access_clinic( get_current_user_id(), $current_patient['clinic_id'] ) ) {
|
||||
return new \WP_Error(
|
||||
'insufficient_permissions',
|
||||
'You do not have permission to update this patient',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced validation
|
||||
$validation = self::validate_patient_business_rules( $patient_data, $current_patient['clinic_id'], $patient_id );
|
||||
if ( is_wp_error( $validation ) ) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
// Add update metadata
|
||||
$patient_data['updated_by'] = get_current_user_id();
|
||||
$patient_data['updated_at'] = current_time( 'mysql' );
|
||||
|
||||
// Update patient
|
||||
$result = Patient::update( $patient_id, $patient_data );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Handle emergency contact changes
|
||||
self::handle_emergency_contact_changes( $patient_id, $current_patient, $patient_data );
|
||||
|
||||
// Trigger action
|
||||
do_action( 'kivicare_patient_updated', $patient_id, $patient_data );
|
||||
|
||||
// Return updated patient data
|
||||
return self::get_patient_with_metadata( $patient_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient with enhanced metadata
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @return array|WP_Error Patient data with metadata or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_patient_with_metadata( $patient_id ) {
|
||||
$patient = Patient::get_by_id( $patient_id );
|
||||
|
||||
if ( ! $patient ) {
|
||||
return new \WP_Error(
|
||||
'patient_not_found',
|
||||
'Patient not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_access_clinic( get_current_user_id(), $patient['clinic_id'] ) ) {
|
||||
return new \WP_Error(
|
||||
'access_denied',
|
||||
'You do not have access to this patient',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
// Add enhanced metadata
|
||||
$patient['appointments'] = self::get_patient_appointments( $patient_id );
|
||||
$patient['encounters'] = self::get_patient_encounters( $patient_id );
|
||||
$patient['prescriptions'] = self::get_patient_prescriptions( $patient_id );
|
||||
$patient['bills'] = self::get_patient_bills( $patient_id );
|
||||
$patient['medical_history'] = self::get_patient_medical_history( $patient_id );
|
||||
$patient['emergency_contacts'] = self::get_patient_emergency_contacts( $patient_id );
|
||||
|
||||
return $patient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search patients with advanced criteria
|
||||
*
|
||||
* @param string $search_term Search term
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @param array $filters Additional filters
|
||||
* @return array Search results
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function search_patients( $search_term, $clinic_id, $filters = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_access_clinic( get_current_user_id(), $clinic_id ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
// Build search query
|
||||
$where_clauses = array( "p.clinic_id = %d" );
|
||||
$where_values = array( $clinic_id );
|
||||
|
||||
// Search term
|
||||
if ( ! empty( $search_term ) ) {
|
||||
$where_clauses[] = "(p.first_name LIKE %s OR p.last_name LIKE %s OR p.patient_id LIKE %s OR p.contact_no LIKE %s OR p.user_email LIKE %s)";
|
||||
$search_term = '%' . $wpdb->esc_like( $search_term ) . '%';
|
||||
$where_values = array_merge( $where_values, array_fill( 0, 5, $search_term ) );
|
||||
}
|
||||
|
||||
// Age filter
|
||||
if ( ! empty( $filters['age_min'] ) || ! empty( $filters['age_max'] ) ) {
|
||||
if ( ! empty( $filters['age_min'] ) ) {
|
||||
$where_clauses[] = "TIMESTAMPDIFF(YEAR, p.dob, CURDATE()) >= %d";
|
||||
$where_values[] = (int) $filters['age_min'];
|
||||
}
|
||||
if ( ! empty( $filters['age_max'] ) ) {
|
||||
$where_clauses[] = "TIMESTAMPDIFF(YEAR, p.dob, CURDATE()) <= %d";
|
||||
$where_values[] = (int) $filters['age_max'];
|
||||
}
|
||||
}
|
||||
|
||||
// Gender filter
|
||||
if ( ! empty( $filters['gender'] ) ) {
|
||||
$where_clauses[] = "p.gender = %s";
|
||||
$where_values[] = $filters['gender'];
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ( isset( $filters['status'] ) ) {
|
||||
$where_clauses[] = "p.status = %d";
|
||||
$where_values[] = $filters['status'];
|
||||
} else {
|
||||
$where_clauses[] = "p.status = 1"; // Active by default
|
||||
}
|
||||
|
||||
$where_sql = implode( ' AND ', $where_clauses );
|
||||
|
||||
$query = "SELECT p.*,
|
||||
COUNT(DISTINCT a.id) as appointment_count,
|
||||
MAX(a.appointment_start_date) as last_visit
|
||||
FROM {$wpdb->prefix}kc_patients p
|
||||
LEFT JOIN {$wpdb->prefix}kc_appointments a ON p.id = a.patient_id
|
||||
WHERE {$where_sql}
|
||||
GROUP BY p.id
|
||||
ORDER BY p.first_name, p.last_name
|
||||
LIMIT 50";
|
||||
|
||||
$results = $wpdb->get_results( $wpdb->prepare( $query, $where_values ), ARRAY_A );
|
||||
|
||||
return array_map( function( $patient ) {
|
||||
$patient['id'] = (int) $patient['id'];
|
||||
$patient['appointment_count'] = (int) $patient['appointment_count'];
|
||||
$patient['age'] = $patient['dob'] ? self::calculate_age( $patient['dob'] ) : null;
|
||||
return $patient;
|
||||
}, $results );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient dashboard data
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @return array|WP_Error Dashboard data or error
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static function get_patient_dashboard( $patient_id ) {
|
||||
$patient = Patient::get_by_id( $patient_id );
|
||||
|
||||
if ( ! $patient ) {
|
||||
return new \WP_Error(
|
||||
'patient_not_found',
|
||||
'Patient not found',
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if ( ! Permission_Service::can_access_clinic( get_current_user_id(), $patient['clinic_id'] ) ) {
|
||||
return new \WP_Error(
|
||||
'access_denied',
|
||||
'You do not have access to this patient dashboard',
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
$dashboard = array();
|
||||
|
||||
// Basic patient info
|
||||
$dashboard['patient'] = $patient;
|
||||
$dashboard['patient']['age'] = self::calculate_age( $patient['dob'] );
|
||||
|
||||
// Recent activity
|
||||
$dashboard['recent_appointments'] = self::get_recent_appointments( $patient_id, 5 );
|
||||
$dashboard['recent_encounters'] = self::get_recent_encounters( $patient_id, 5 );
|
||||
$dashboard['active_prescriptions'] = self::get_active_prescriptions( $patient_id );
|
||||
|
||||
// Medical summary
|
||||
$dashboard['medical_summary'] = self::get_medical_summary( $patient_id );
|
||||
|
||||
// Upcoming appointments
|
||||
$dashboard['upcoming_appointments'] = self::get_upcoming_appointments( $patient_id );
|
||||
|
||||
// Outstanding bills
|
||||
$dashboard['outstanding_bills'] = self::get_outstanding_bills( $patient_id );
|
||||
|
||||
return $dashboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique patient ID
|
||||
*
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @return string Patient ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function generate_patient_id( $clinic_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$prefix = 'P' . str_pad( $clinic_id, 3, '0', STR_PAD_LEFT );
|
||||
|
||||
// Get the highest existing patient ID for this clinic
|
||||
$max_id = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT MAX(CAST(SUBSTRING(patient_id, 5) AS UNSIGNED))
|
||||
FROM {$wpdb->prefix}kc_patients
|
||||
WHERE clinic_id = %d AND patient_id LIKE %s",
|
||||
$clinic_id,
|
||||
$prefix . '%'
|
||||
)
|
||||
);
|
||||
|
||||
$next_number = ( $max_id ? $max_id + 1 : 1 );
|
||||
|
||||
return $prefix . str_pad( $next_number, 6, '0', STR_PAD_LEFT );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate patient business rules
|
||||
*
|
||||
* @param array $patient_data Patient data
|
||||
* @param int $clinic_id Clinic ID
|
||||
* @param int $patient_id Patient ID (for updates)
|
||||
* @return bool|WP_Error True if valid, WP_Error otherwise
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function validate_patient_business_rules( $patient_data, $clinic_id, $patient_id = null ) {
|
||||
global $wpdb;
|
||||
|
||||
$errors = array();
|
||||
|
||||
// Check for duplicate patient ID in clinic
|
||||
if ( ! empty( $patient_data['patient_id'] ) ) {
|
||||
$existing_query = "SELECT id FROM {$wpdb->prefix}kc_patients WHERE patient_id = %s AND clinic_id = %d";
|
||||
$query_params = array( $patient_data['patient_id'], $clinic_id );
|
||||
|
||||
if ( $patient_id ) {
|
||||
$existing_query .= " AND id != %d";
|
||||
$query_params[] = $patient_id;
|
||||
}
|
||||
|
||||
$existing_patient = $wpdb->get_var( $wpdb->prepare( $existing_query, $query_params ) );
|
||||
|
||||
if ( $existing_patient ) {
|
||||
$errors[] = 'A patient with this ID already exists in the clinic';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate contact information format
|
||||
if ( ! empty( $patient_data['contact_no'] ) ) {
|
||||
if ( ! preg_match( '/^[+]?[0-9\s\-\(\)]{7,20}$/', $patient_data['contact_no'] ) ) {
|
||||
$errors[] = 'Invalid contact number format';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if ( ! empty( $patient_data['user_email'] ) ) {
|
||||
if ( ! is_email( $patient_data['user_email'] ) ) {
|
||||
$errors[] = 'Invalid email format';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate date of birth
|
||||
if ( ! empty( $patient_data['dob'] ) ) {
|
||||
$dob = strtotime( $patient_data['dob'] );
|
||||
if ( ! $dob || $dob > time() ) {
|
||||
$errors[] = 'Invalid date of birth';
|
||||
}
|
||||
|
||||
// Check for reasonable age limits
|
||||
$age = self::calculate_age( $patient_data['dob'] );
|
||||
if ( $age > 150 ) {
|
||||
$errors[] = 'Date of birth indicates unrealistic age';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate gender
|
||||
if ( ! empty( $patient_data['gender'] ) ) {
|
||||
$valid_genders = array( 'male', 'female', 'other' );
|
||||
if ( ! in_array( strtolower( $patient_data['gender'] ), $valid_genders ) ) {
|
||||
$errors[] = 'Invalid gender value';
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $errors ) ) {
|
||||
return new \WP_Error(
|
||||
'patient_business_validation_failed',
|
||||
'Patient business validation failed',
|
||||
array(
|
||||
'status' => 400,
|
||||
'errors' => $errors
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup patient defaults after creation
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @param array $patient_data Patient data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function setup_patient_defaults( $patient_id, $patient_data ) {
|
||||
// Initialize medical history
|
||||
self::initialize_medical_history( $patient_id );
|
||||
|
||||
// Setup default preferences
|
||||
self::setup_default_preferences( $patient_id );
|
||||
|
||||
// Create patient folder structure (if needed)
|
||||
self::create_patient_folder_structure( $patient_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate age from date of birth
|
||||
*
|
||||
* @param string $dob Date of birth
|
||||
* @return int Age in years
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function calculate_age( $dob ) {
|
||||
if ( empty( $dob ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$birth_date = new \DateTime( $dob );
|
||||
$today = new \DateTime();
|
||||
|
||||
return $birth_date->diff( $today )->y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient appointments
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @param int $limit Limit
|
||||
* @return array Appointments
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_patient_appointments( $patient_id, $limit = null ) {
|
||||
global $wpdb;
|
||||
|
||||
$query = "SELECT a.*, d.display_name as doctor_name, c.name as clinic_name
|
||||
FROM {$wpdb->prefix}kc_appointments a
|
||||
LEFT JOIN {$wpdb->prefix}users d ON a.doctor_id = d.ID
|
||||
LEFT JOIN {$wpdb->prefix}kc_clinics c ON a.clinic_id = c.id
|
||||
WHERE a.patient_id = %d
|
||||
ORDER BY a.appointment_start_date DESC";
|
||||
|
||||
if ( $limit ) {
|
||||
$query .= " LIMIT {$limit}";
|
||||
}
|
||||
|
||||
return $wpdb->get_results( $wpdb->prepare( $query, $patient_id ), ARRAY_A );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient encounters
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @param int $limit Limit
|
||||
* @return array Encounters
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_patient_encounters( $patient_id, $limit = null ) {
|
||||
global $wpdb;
|
||||
|
||||
$query = "SELECT e.*, d.display_name as doctor_name
|
||||
FROM {$wpdb->prefix}kc_encounters e
|
||||
LEFT JOIN {$wpdb->prefix}users d ON e.doctor_id = d.ID
|
||||
WHERE e.patient_id = %d
|
||||
ORDER BY e.encounter_date DESC";
|
||||
|
||||
if ( $limit ) {
|
||||
$query .= " LIMIT {$limit}";
|
||||
}
|
||||
|
||||
return $wpdb->get_results( $wpdb->prepare( $query, $patient_id ), ARRAY_A );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient prescriptions
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @return array Prescriptions
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_patient_prescriptions( $patient_id ) {
|
||||
global $wpdb;
|
||||
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT p.*, d.display_name as doctor_name
|
||||
FROM {$wpdb->prefix}kc_prescriptions p
|
||||
LEFT JOIN {$wpdb->prefix}users d ON p.doctor_id = d.ID
|
||||
WHERE p.patient_id = %d
|
||||
ORDER BY p.created_at DESC",
|
||||
$patient_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient bills
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @return array Bills
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_patient_bills( $patient_id ) {
|
||||
global $wpdb;
|
||||
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_bills
|
||||
WHERE patient_id = %d
|
||||
ORDER BY created_at DESC",
|
||||
$patient_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize medical history for patient
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function initialize_medical_history( $patient_id ) {
|
||||
$default_history = array(
|
||||
'allergies' => array(),
|
||||
'medications' => array(),
|
||||
'conditions' => array(),
|
||||
'surgeries' => array(),
|
||||
'family_history' => array()
|
||||
);
|
||||
|
||||
update_option( "kivicare_patient_{$patient_id}_medical_history", $default_history );
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup default preferences
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function setup_default_preferences( $patient_id ) {
|
||||
$default_preferences = array(
|
||||
'appointment_reminders' => true,
|
||||
'email_notifications' => true,
|
||||
'sms_notifications' => false,
|
||||
'preferred_language' => 'en',
|
||||
'preferred_communication' => 'email'
|
||||
);
|
||||
|
||||
update_option( "kivicare_patient_{$patient_id}_preferences", $default_preferences );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient medical history
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @return array Medical history
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_patient_medical_history( $patient_id ) {
|
||||
return get_option( "kivicare_patient_{$patient_id}_medical_history", array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patient emergency contacts
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @return array Emergency contacts
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function get_patient_emergency_contacts( $patient_id ) {
|
||||
return get_option( "kivicare_patient_{$patient_id}_emergency_contacts", array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle emergency contact changes
|
||||
*
|
||||
* @param int $patient_id Patient ID
|
||||
* @param array $current_data Current patient data
|
||||
* @param array $new_data New patient data
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private static function handle_emergency_contact_changes( $patient_id, $current_data, $new_data ) {
|
||||
// This would handle emergency contact updates
|
||||
// Implementation depends on how emergency contacts are stored
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handlers
|
||||
*/
|
||||
public static function on_patient_created( $patient_id, $patient_data ) {
|
||||
error_log( "KiviCare: New patient created - ID: {$patient_id}, Name: " . ( $patient_data['first_name'] ?? 'Unknown' ) );
|
||||
}
|
||||
|
||||
public static function on_patient_updated( $patient_id, $patient_data ) {
|
||||
error_log( "KiviCare: Patient updated - ID: {$patient_id}" );
|
||||
wp_cache_delete( "patient_{$patient_id}", 'kivicare' );
|
||||
}
|
||||
|
||||
public static function on_patient_deleted( $patient_id ) {
|
||||
// Clean up related data
|
||||
delete_option( "kivicare_patient_{$patient_id}_medical_history" );
|
||||
delete_option( "kivicare_patient_{$patient_id}_preferences" );
|
||||
delete_option( "kivicare_patient_{$patient_id}_emergency_contacts" );
|
||||
|
||||
wp_cache_delete( "patient_{$patient_id}", 'kivicare' );
|
||||
error_log( "KiviCare: Patient deleted - ID: {$patient_id}" );
|
||||
}
|
||||
|
||||
// Additional helper methods would be implemented here...
|
||||
private static function get_recent_appointments( $patient_id, $limit ) {
|
||||
return self::get_patient_appointments( $patient_id, $limit );
|
||||
}
|
||||
|
||||
private static function get_recent_encounters( $patient_id, $limit ) {
|
||||
return self::get_patient_encounters( $patient_id, $limit );
|
||||
}
|
||||
|
||||
private static function get_active_prescriptions( $patient_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_prescriptions
|
||||
WHERE patient_id = %d AND status = 'active'
|
||||
ORDER BY created_at DESC",
|
||||
$patient_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_medical_summary( $patient_id ) {
|
||||
return array(
|
||||
'medical_history' => self::get_patient_medical_history( $patient_id ),
|
||||
'last_visit' => self::get_last_visit_date( $patient_id ),
|
||||
'chronic_conditions' => self::get_chronic_conditions( $patient_id ),
|
||||
'active_medications' => self::get_active_medications( $patient_id )
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_upcoming_appointments( $patient_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT a.*, d.display_name as doctor_name
|
||||
FROM {$wpdb->prefix}kc_appointments a
|
||||
LEFT JOIN {$wpdb->prefix}users d ON a.doctor_id = d.ID
|
||||
WHERE a.patient_id = %d AND a.appointment_start_date > NOW()
|
||||
ORDER BY a.appointment_start_date ASC
|
||||
LIMIT 5",
|
||||
$patient_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_outstanding_bills( $patient_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_bills
|
||||
WHERE patient_id = %d AND status = 'pending'
|
||||
ORDER BY created_at DESC",
|
||||
$patient_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_last_visit_date( $patient_id ) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT MAX(appointment_start_date) FROM {$wpdb->prefix}kc_appointments
|
||||
WHERE patient_id = %d AND status = 2",
|
||||
$patient_id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static function get_chronic_conditions( $patient_id ) {
|
||||
$medical_history = self::get_patient_medical_history( $patient_id );
|
||||
return isset( $medical_history['conditions'] ) ?
|
||||
array_filter( $medical_history['conditions'], function( $condition ) {
|
||||
return isset( $condition['chronic'] ) && $condition['chronic'];
|
||||
} ) : array();
|
||||
}
|
||||
|
||||
private static function get_active_medications( $patient_id ) {
|
||||
$medical_history = self::get_patient_medical_history( $patient_id );
|
||||
return isset( $medical_history['medications'] ) ?
|
||||
array_filter( $medical_history['medications'], function( $medication ) {
|
||||
return isset( $medication['active'] ) && $medication['active'];
|
||||
} ) : array();
|
||||
}
|
||||
|
||||
private static function create_patient_folder_structure( $patient_id ) {
|
||||
// Implementation for creating patient document folders if needed
|
||||
// This would depend on the file management system
|
||||
}
|
||||
}
|
||||
1031
src/includes/services/database/class-prescription-service.php
Normal file
1031
src/includes/services/database/class-prescription-service.php
Normal file
File diff suppressed because it is too large
Load Diff
299
src/kivicare-api.php
Normal file
299
src/kivicare-api.php
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: KiviCare API
|
||||
* Plugin URI: https://descomplicar.pt
|
||||
* Description: REST API extension for KiviCare plugin - Healthcare management system
|
||||
* Version: 1.0.0
|
||||
* Author: Descomplicar® Crescimento Digital
|
||||
* Author URI: https://descomplicar.pt
|
||||
* Text Domain: kivicare-api
|
||||
* Domain Path: /languages
|
||||
* Requires at least: 6.0
|
||||
* Tested up to: 6.4
|
||||
* Requires PHP: 8.1
|
||||
* License: GPL v2 or later
|
||||
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
*
|
||||
* Network: false
|
||||
*
|
||||
* KiviCare API is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 2 of the License, or
|
||||
* any later version.
|
||||
*
|
||||
* KiviCare API is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* @package KiviCare_API
|
||||
*/
|
||||
|
||||
// Exit if accessed directly.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Define plugin constants
|
||||
define( 'KIVICARE_API_VERSION', '1.0.0' );
|
||||
define( 'KIVICARE_API_PLUGIN_FILE', __FILE__ );
|
||||
define( 'KIVICARE_API_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'KIVICARE_API_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
define( 'KIVICARE_API_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
|
||||
|
||||
/**
|
||||
* Main KiviCare API class.
|
||||
*
|
||||
* @class KiviCare_API
|
||||
*/
|
||||
final class KiviCare_API {
|
||||
|
||||
/**
|
||||
* The single instance of the class.
|
||||
*
|
||||
* @var KiviCare_API
|
||||
* @since 1.0.0
|
||||
*/
|
||||
protected static $_instance = null;
|
||||
|
||||
/**
|
||||
* Main KiviCare_API Instance.
|
||||
*
|
||||
* Ensures only one instance of KiviCare_API is loaded or can be loaded.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @static
|
||||
* @return KiviCare_API - Main instance.
|
||||
*/
|
||||
public static function instance() {
|
||||
if ( is_null( self::$_instance ) ) {
|
||||
self::$_instance = new self();
|
||||
}
|
||||
return self::$_instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* KiviCare_API Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->define_constants();
|
||||
$this->includes();
|
||||
$this->init_hooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Define KiviCare API Constants.
|
||||
*/
|
||||
private function define_constants() {
|
||||
$this->define( 'KIVICARE_API_ABSPATH', dirname( KIVICARE_API_PLUGIN_FILE ) . '/' );
|
||||
$this->define( 'KIVICARE_API_CACHE_TTL', 3600 );
|
||||
$this->define( 'KIVICARE_API_DEBUG', WP_DEBUG );
|
||||
}
|
||||
|
||||
/**
|
||||
* Define constant if not already set.
|
||||
*
|
||||
* @param string $name Constant name.
|
||||
* @param string|bool $value Constant value.
|
||||
*/
|
||||
private function define( $name, $value ) {
|
||||
if ( ! defined( $name ) ) {
|
||||
define( $name, $value );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Include required core files.
|
||||
*/
|
||||
public function includes() {
|
||||
/**
|
||||
* Core classes.
|
||||
*/
|
||||
include_once KIVICARE_API_ABSPATH . 'includes/class-api-init.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook into actions and filters.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function init_hooks() {
|
||||
add_action( 'init', array( $this, 'init' ), 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Init KiviCare API when WordPress Initialises.
|
||||
*/
|
||||
public function init() {
|
||||
// Before init action.
|
||||
do_action( 'before_kivicare_api_init' );
|
||||
|
||||
// Set up localisation.
|
||||
$this->load_plugin_textdomain();
|
||||
|
||||
// Initialize API.
|
||||
if ( class_exists( 'KiviCare_API_Init' ) ) {
|
||||
KiviCare_API_Init::instance();
|
||||
}
|
||||
|
||||
// Init action.
|
||||
do_action( 'kivicare_api_init' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Localisation files.
|
||||
*/
|
||||
public function load_plugin_textdomain() {
|
||||
load_plugin_textdomain( 'kivicare-api', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the plugin url.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function plugin_url() {
|
||||
return untrailingslashit( plugins_url( '/', KIVICARE_API_PLUGIN_FILE ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the plugin path.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function plugin_path() {
|
||||
return untrailingslashit( plugin_dir_path( KIVICARE_API_PLUGIN_FILE ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the template path.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function template_path() {
|
||||
return apply_filters( 'kivicare_api_template_path', 'kivicare-api/' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main instance of KiviCare_API.
|
||||
*
|
||||
* Returns the main instance of KiviCare_API to prevent the need to use globals.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @return KiviCare_API
|
||||
*/
|
||||
function kivicare_api() {
|
||||
return KiviCare_API::instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if KiviCare plugin is active.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
function kivicare_api_is_kivicare_active() {
|
||||
return is_plugin_active( 'kivicare-clinic-&-patient-management-system/kivicare-clinic-&-patient-management-system.php' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin activation hook.
|
||||
*/
|
||||
function kivicare_api_activate() {
|
||||
// Check if KiviCare plugin is active
|
||||
if ( ! kivicare_api_is_kivicare_active() ) {
|
||||
wp_die(
|
||||
esc_html__( 'KiviCare Plugin is required to activate KiviCare API.', 'kivicare-api' ),
|
||||
esc_html__( 'Plugin Dependency Error', 'kivicare-api' ),
|
||||
array( 'back_link' => true )
|
||||
);
|
||||
}
|
||||
|
||||
// Create capabilities for roles
|
||||
$roles = array( 'administrator', 'doctor', 'patient', 'kivicare_receptionist' );
|
||||
|
||||
foreach ( $roles as $role_name ) {
|
||||
$role = get_role( $role_name );
|
||||
if ( $role ) {
|
||||
$role->add_cap( 'manage_kivicare_api' );
|
||||
|
||||
// Add specific capabilities based on role
|
||||
switch ( $role_name ) {
|
||||
case 'administrator':
|
||||
$role->add_cap( 'kivicare_api_full_access' );
|
||||
break;
|
||||
case 'doctor':
|
||||
$role->add_cap( 'kivicare_api_medical_access' );
|
||||
break;
|
||||
case 'patient':
|
||||
$role->add_cap( 'kivicare_api_patient_access' );
|
||||
break;
|
||||
case 'kivicare_receptionist':
|
||||
$role->add_cap( 'kivicare_api_reception_access' );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush rewrite rules to ensure REST API routes work
|
||||
flush_rewrite_rules();
|
||||
|
||||
// Set activation flag
|
||||
update_option( 'kivicare_api_activated', true );
|
||||
update_option( 'kivicare_api_version', KIVICARE_API_VERSION );
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin deactivation hook.
|
||||
*/
|
||||
function kivicare_api_deactivate() {
|
||||
// Remove capabilities
|
||||
$roles = array( 'administrator', 'doctor', 'patient', 'kivicare_receptionist' );
|
||||
$capabilities = array(
|
||||
'manage_kivicare_api',
|
||||
'kivicare_api_full_access',
|
||||
'kivicare_api_medical_access',
|
||||
'kivicare_api_patient_access',
|
||||
'kivicare_api_reception_access'
|
||||
);
|
||||
|
||||
foreach ( $roles as $role_name ) {
|
||||
$role = get_role( $role_name );
|
||||
if ( $role ) {
|
||||
foreach ( $capabilities as $cap ) {
|
||||
$role->remove_cap( $cap );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush rewrite rules
|
||||
flush_rewrite_rules();
|
||||
|
||||
// Clean up options
|
||||
delete_option( 'kivicare_api_activated' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin uninstall hook.
|
||||
*/
|
||||
function kivicare_api_uninstall() {
|
||||
// Clean up all plugin data
|
||||
delete_option( 'kivicare_api_version' );
|
||||
delete_option( 'kivicare_api_activated' );
|
||||
|
||||
// Clear any cached data
|
||||
wp_cache_flush();
|
||||
}
|
||||
|
||||
// Hooks
|
||||
register_activation_hook( __FILE__, 'kivicare_api_activate' );
|
||||
register_deactivation_hook( __FILE__, 'kivicare_api_deactivate' );
|
||||
register_uninstall_hook( __FILE__, 'kivicare_api_uninstall' );
|
||||
|
||||
// Global for backwards compatibility.
|
||||
$GLOBALS['kivicare_api'] = kivicare_api();
|
||||
245
test-runner.php
Normal file
245
test-runner.php
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Simple test runner to verify TDD RED phase.
|
||||
*
|
||||
* This script verifies that our contract tests fail as expected (RED phase).
|
||||
*/
|
||||
|
||||
// Mock WordPress environment for testing
|
||||
define( 'ABSPATH', __DIR__ . '/' );
|
||||
define( 'WP_DEBUG', true );
|
||||
|
||||
// Mock WordPress functions
|
||||
function rest_ensure_response( $data ) {
|
||||
return new WP_REST_Response( $data );
|
||||
}
|
||||
|
||||
function current_time( $format ) {
|
||||
return date( $format );
|
||||
}
|
||||
|
||||
function wp_generate_password( $length, $special_chars = true ) {
|
||||
return substr( str_shuffle( 'abcdefghijklmnopqrstuvwxyz0123456789' ), 0, $length );
|
||||
}
|
||||
|
||||
function get_user_by( $field, $value ) {
|
||||
return null; // Simulate user not found for RED phase
|
||||
}
|
||||
|
||||
function wp_set_current_user( $user_id ) {
|
||||
global $current_user_id;
|
||||
$current_user_id = $user_id;
|
||||
}
|
||||
|
||||
function current_user_can( $capability ) {
|
||||
return false; // All permissions fail in RED phase
|
||||
}
|
||||
|
||||
class WP_REST_Response {
|
||||
private $data;
|
||||
private $status = 200;
|
||||
|
||||
public function __construct( $data = null, $status = 200 ) {
|
||||
$this->data = $data;
|
||||
$this->status = $status;
|
||||
}
|
||||
|
||||
public function get_data() {
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function get_status() {
|
||||
return $this->status;
|
||||
}
|
||||
}
|
||||
|
||||
class WP_REST_Request {
|
||||
private $method;
|
||||
private $route;
|
||||
private $params = array();
|
||||
|
||||
public function __construct( $method, $route ) {
|
||||
$this->method = $method;
|
||||
$this->route = $route;
|
||||
}
|
||||
|
||||
public function set_body_params( $params ) {
|
||||
$this->params = $params;
|
||||
}
|
||||
|
||||
public function get_route() {
|
||||
return $this->route;
|
||||
}
|
||||
}
|
||||
|
||||
class WP_REST_Server {
|
||||
const READABLE = 'GET';
|
||||
const CREATABLE = 'POST';
|
||||
const EDITABLE = 'PUT';
|
||||
const DELETABLE = 'DELETE';
|
||||
|
||||
public function dispatch( $request ) {
|
||||
// All endpoints return 404 in RED phase (not implemented)
|
||||
return new WP_REST_Response( array(
|
||||
'code' => 'rest_no_route',
|
||||
'message' => 'No route was found matching the URL and request method.',
|
||||
), 404 );
|
||||
}
|
||||
}
|
||||
|
||||
// Simple test class
|
||||
class SimpleTestCase {
|
||||
protected $server;
|
||||
protected $admin_user = 1;
|
||||
protected $doctor_user = 2;
|
||||
protected $patient_user = 3;
|
||||
protected $receptionist_user = 4;
|
||||
|
||||
public function setUp() {
|
||||
global $wp_rest_server;
|
||||
$this->server = $wp_rest_server = new WP_REST_Server;
|
||||
}
|
||||
|
||||
protected function make_request( $endpoint, $method = 'GET', $data = array(), $user_id = null ) {
|
||||
$request = new WP_REST_Request( $method, $endpoint );
|
||||
if ( ! empty( $data ) ) {
|
||||
$request->set_body_params( $data );
|
||||
}
|
||||
if ( $user_id ) {
|
||||
wp_set_current_user( $user_id );
|
||||
}
|
||||
return $this->server->dispatch( $request );
|
||||
}
|
||||
|
||||
protected function assertRestResponse( $response, $expected_status, $message = '' ) {
|
||||
$actual_status = $response->get_status();
|
||||
if ( $actual_status !== $expected_status ) {
|
||||
echo "FAIL: {$message} - Expected {$expected_status}, got {$actual_status}\n";
|
||||
return false;
|
||||
}
|
||||
echo "PASS: Test assertion passed\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function create_test_clinic() {
|
||||
return 1; // Mock clinic ID
|
||||
}
|
||||
|
||||
protected function create_test_appointment( $clinic_id, $doctor_id, $patient_id ) {
|
||||
return 1; // Mock appointment ID
|
||||
}
|
||||
}
|
||||
|
||||
// Run contract tests
|
||||
echo "🧪 RUNNING TDD RED PHASE VERIFICATION\n";
|
||||
echo "=====================================\n\n";
|
||||
|
||||
class TestAuthEndpointsContract extends SimpleTestCase {
|
||||
public function test_auth_login_endpoint_contract() {
|
||||
$this->setUp();
|
||||
|
||||
echo "Testing: POST /wp-json/kivicare/v1/auth/login\n";
|
||||
|
||||
$login_data = array(
|
||||
'username' => 'test_doctor',
|
||||
'password' => 'password123',
|
||||
);
|
||||
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/auth/login', 'POST', $login_data );
|
||||
|
||||
// Should fail (404) because endpoint is not implemented yet
|
||||
$this->assertRestResponse( $response, 404, 'Login endpoint should not exist yet (TDD RED phase)' );
|
||||
|
||||
return $response->get_status() === 404;
|
||||
}
|
||||
}
|
||||
|
||||
class TestClinicEndpointsContract extends SimpleTestCase {
|
||||
public function test_get_clinics_endpoint_contract() {
|
||||
$this->setUp();
|
||||
|
||||
echo "Testing: GET /wp-json/kivicare/v1/clinics\n";
|
||||
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/clinics' );
|
||||
|
||||
// Should fail (404) because endpoint is not implemented yet
|
||||
$this->assertRestResponse( $response, 404, 'Clinics GET endpoint should not exist yet (TDD RED phase)' );
|
||||
|
||||
return $response->get_status() === 404;
|
||||
}
|
||||
}
|
||||
|
||||
class TestPatientEndpointsContract extends SimpleTestCase {
|
||||
public function test_get_patients_endpoint_contract() {
|
||||
$this->setUp();
|
||||
|
||||
echo "Testing: GET /wp-json/kivicare/v1/patients\n";
|
||||
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/patients' );
|
||||
|
||||
// Should fail (404) because endpoint is not implemented yet
|
||||
$this->assertRestResponse( $response, 404, 'Patients GET endpoint should not exist yet (TDD RED phase)' );
|
||||
|
||||
return $response->get_status() === 404;
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests
|
||||
$test_classes = array(
|
||||
'TestAuthEndpointsContract',
|
||||
'TestClinicEndpointsContract',
|
||||
'TestPatientEndpointsContract',
|
||||
);
|
||||
|
||||
$total_tests = 0;
|
||||
$failed_tests = 0;
|
||||
|
||||
foreach ( $test_classes as $class ) {
|
||||
echo "\n--- Running {$class} ---\n";
|
||||
|
||||
$test = new $class();
|
||||
$methods = get_class_methods( $class );
|
||||
|
||||
foreach ( $methods as $method ) {
|
||||
if ( strpos( $method, 'test_' ) === 0 ) {
|
||||
$total_tests++;
|
||||
echo "\nRunning {$method}:\n";
|
||||
|
||||
$result = $test->$method();
|
||||
|
||||
if ( ! $result ) {
|
||||
$failed_tests++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n\n🎯 TDD RED PHASE VERIFICATION RESULTS\n";
|
||||
echo "====================================\n";
|
||||
echo "Total tests: {$total_tests}\n";
|
||||
echo "Expected failures (404 - endpoints not implemented): {$failed_tests}\n";
|
||||
echo "Success rate: " . ( ( $total_tests - $failed_tests ) / $total_tests * 100 ) . "%\n\n";
|
||||
|
||||
if ( $failed_tests === $total_tests ) {
|
||||
echo "✅ PERFECT! All tests failed as expected (TDD RED phase).\n";
|
||||
echo "📋 This confirms our contract tests are ready and the endpoints don't exist yet.\n";
|
||||
echo "🚀 Ready to proceed to implementation phase (GREEN).\n";
|
||||
} else {
|
||||
echo "⚠️ WARNING: Some tests passed when they should fail.\n";
|
||||
echo "🔍 This might indicate endpoints already exist or test setup issues.\n";
|
||||
}
|
||||
|
||||
echo "\n📊 NEXT STEPS:\n";
|
||||
echo "1. ✅ Contract tests created and failing (RED phase) - DONE\n";
|
||||
echo "2. 🔄 Implement JWT authentication service\n";
|
||||
echo "3. 🔄 Create model classes for 8 entities\n";
|
||||
echo "4. 🔄 Build REST API endpoints\n";
|
||||
echo "5. 🔄 Run tests again to see GREEN phase\n";
|
||||
|
||||
echo "\n🎛️ Master Orchestrator Status: Phase 3.2 (TDD Tests) COMPLETE\n";
|
||||
echo "Ready to proceed to Phase 3.3 (Core Implementation)\n";
|
||||
246
tests/bootstrap.php
Normal file
246
tests/bootstrap.php
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* PHPUnit bootstrap file for KiviCare API tests.
|
||||
*
|
||||
* @package KiviCare_API\Tests
|
||||
*/
|
||||
|
||||
// Define testing environment constants
|
||||
define( 'KIVICARE_API_TESTS', true );
|
||||
define( 'WP_USE_THEMES', false );
|
||||
|
||||
// Set the timezone to avoid warnings
|
||||
if ( ! ini_get( 'date.timezone' ) ) {
|
||||
date_default_timezone_set( 'UTC' );
|
||||
}
|
||||
|
||||
// Determine WordPress test directory
|
||||
$_tests_dir = getenv( 'WP_TESTS_DIR' );
|
||||
if ( ! $_tests_dir ) {
|
||||
$_tests_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/wordpress-tests-lib';
|
||||
}
|
||||
|
||||
// Check if WordPress test suite exists
|
||||
if ( ! file_exists( $_tests_dir . '/includes/functions.php' ) ) {
|
||||
echo "Could not find WordPress test suite at: $_tests_dir\n";
|
||||
echo "Please install WordPress test suite first:\n";
|
||||
echo "bash bin/install-wp-tests.sh wordpress_test root '' localhost latest\n";
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
// Give access to tests_add_filter() function
|
||||
require_once $_tests_dir . '/includes/functions.php';
|
||||
|
||||
/**
|
||||
* Manually load the plugin being tested.
|
||||
*/
|
||||
function _manually_load_plugin() {
|
||||
// Load KiviCare plugin if available (mock if not)
|
||||
$kivicare_plugin_file = WP_PLUGIN_DIR . '/kivicare-clinic-&-patient-management-system/kivicare-clinic-&-patient-management-system.php';
|
||||
|
||||
if ( file_exists( $kivicare_plugin_file ) ) {
|
||||
require $kivicare_plugin_file;
|
||||
} else {
|
||||
// Mock KiviCare plugin functionality for testing
|
||||
require dirname( __FILE__ ) . '/mocks/mock-kivicare.php';
|
||||
}
|
||||
|
||||
// Load our plugin
|
||||
require dirname( dirname( __FILE__ ) ) . '/src/kivicare-api.php';
|
||||
|
||||
// Activate our plugin
|
||||
activate_plugin( 'kivicare-api/kivicare-api.php' );
|
||||
}
|
||||
tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' );
|
||||
|
||||
/**
|
||||
* Setup test database tables.
|
||||
*/
|
||||
function _setup_test_tables() {
|
||||
global $wpdb;
|
||||
|
||||
// Create KiviCare test tables
|
||||
require dirname( __FILE__ ) . '/setup/test-database.php';
|
||||
KiviCare_API_Test_Database::create_tables();
|
||||
KiviCare_API_Test_Database::insert_sample_data();
|
||||
}
|
||||
tests_add_filter( 'wp_install', '_setup_test_tables' );
|
||||
|
||||
// Start up the WP testing environment
|
||||
require $_tests_dir . '/includes/bootstrap.php';
|
||||
|
||||
// Include Yoast PHPUnit Polyfills for compatibility
|
||||
if ( class_exists( 'Yoast\PHPUnitPolyfills\Autoload' ) ) {
|
||||
require_once dirname( dirname( __FILE__ ) ) . '/vendor/yoast/phpunit-polyfills/phpunitpolyfills-autoload.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Base test case class for KiviCare API tests.
|
||||
*/
|
||||
class KiviCare_API_Test_Case extends WP_UnitTestCase {
|
||||
|
||||
/**
|
||||
* Setup before each test.
|
||||
*/
|
||||
public function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Clear any cached data
|
||||
wp_cache_flush();
|
||||
|
||||
// Set up REST server
|
||||
global $wp_rest_server;
|
||||
$this->server = $wp_rest_server = new WP_REST_Server;
|
||||
do_action( 'rest_api_init' );
|
||||
|
||||
// Create test user with proper roles
|
||||
$this->create_test_users();
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown after each test.
|
||||
*/
|
||||
public function tearDown(): void {
|
||||
// Clear any cached data
|
||||
wp_cache_flush();
|
||||
|
||||
// Reset REST server
|
||||
global $wp_rest_server;
|
||||
$wp_rest_server = null;
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test users for different roles.
|
||||
*/
|
||||
protected function create_test_users() {
|
||||
// Administrator
|
||||
$this->admin_user = $this->factory->user->create( array(
|
||||
'user_login' => 'test_admin',
|
||||
'user_email' => 'admin@example.com',
|
||||
'role' => 'administrator',
|
||||
) );
|
||||
|
||||
// Doctor
|
||||
$this->doctor_user = $this->factory->user->create( array(
|
||||
'user_login' => 'test_doctor',
|
||||
'user_email' => 'doctor@example.com',
|
||||
'role' => 'doctor',
|
||||
) );
|
||||
|
||||
// Patient
|
||||
$this->patient_user = $this->factory->user->create( array(
|
||||
'user_login' => 'test_patient',
|
||||
'user_email' => 'patient@example.com',
|
||||
'role' => 'patient',
|
||||
) );
|
||||
|
||||
// Receptionist
|
||||
$this->receptionist_user = $this->factory->user->create( array(
|
||||
'user_login' => 'test_receptionist',
|
||||
'user_email' => 'receptionist@example.com',
|
||||
'role' => 'kivicare_receptionist',
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to make REST API requests.
|
||||
*
|
||||
* @param string $endpoint The API endpoint.
|
||||
* @param string $method HTTP method.
|
||||
* @param array $data Request data.
|
||||
* @param int $user_id User ID for authentication.
|
||||
* @return WP_REST_Response
|
||||
*/
|
||||
protected function make_request( $endpoint, $method = 'GET', $data = array(), $user_id = null ) {
|
||||
$request = new WP_REST_Request( $method, $endpoint );
|
||||
|
||||
if ( ! empty( $data ) ) {
|
||||
$request->set_body_params( $data );
|
||||
}
|
||||
|
||||
if ( $user_id ) {
|
||||
wp_set_current_user( $user_id );
|
||||
}
|
||||
|
||||
return $this->server->dispatch( $request );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create test clinic data.
|
||||
*
|
||||
* @return int Clinic ID.
|
||||
*/
|
||||
protected function create_test_clinic() {
|
||||
global $wpdb;
|
||||
|
||||
$clinic_data = array(
|
||||
'name' => 'Test Clinic',
|
||||
'email' => 'test@clinic.com',
|
||||
'telephone_no' => '+351912345678',
|
||||
'address' => 'Test Address',
|
||||
'city' => 'Lisboa',
|
||||
'state' => 'Lisboa',
|
||||
'country' => 'Portugal',
|
||||
'postal_code' => '1000-001',
|
||||
'status' => 1,
|
||||
'clinic_admin_id' => $this->admin_user,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
);
|
||||
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_clinics', $clinic_data );
|
||||
return $wpdb->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create test appointment.
|
||||
*
|
||||
* @param int $clinic_id Clinic ID.
|
||||
* @param int $doctor_id Doctor user ID.
|
||||
* @param int $patient_id Patient user ID.
|
||||
* @return int Appointment ID.
|
||||
*/
|
||||
protected function create_test_appointment( $clinic_id, $doctor_id, $patient_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$appointment_data = array(
|
||||
'appointment_start_date' => gmdate( 'Y-m-d', strtotime( '+1 day' ) ),
|
||||
'appointment_start_time' => '14:30:00',
|
||||
'appointment_end_date' => gmdate( 'Y-m-d', strtotime( '+1 day' ) ),
|
||||
'appointment_end_time' => '15:00:00',
|
||||
'clinic_id' => $clinic_id,
|
||||
'doctor_id' => $doctor_id,
|
||||
'patient_id' => $patient_id,
|
||||
'status' => 1,
|
||||
'visit_type' => 'consultation',
|
||||
'description' => 'Test appointment',
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
);
|
||||
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_appointments', $appointment_data );
|
||||
return $wpdb->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that response has correct REST API format.
|
||||
*
|
||||
* @param WP_REST_Response $response The response object.
|
||||
* @param int $status Expected status code.
|
||||
*/
|
||||
protected function assertRestResponse( $response, $status = 200 ) {
|
||||
$this->assertInstanceOf( 'WP_REST_Response', $response );
|
||||
$this->assertEquals( $status, $response->get_status() );
|
||||
|
||||
if ( $status >= 400 ) {
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'code', $data );
|
||||
$this->assertArrayHasKey( 'message', $data );
|
||||
}
|
||||
}
|
||||
}
|
||||
316
tests/contract/test-appointment-endpoints.php
Normal file
316
tests/contract/test-appointment-endpoints.php
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Contract tests for Appointment endpoints.
|
||||
*
|
||||
* These tests define the API contract and MUST FAIL initially (TDD RED phase).
|
||||
*
|
||||
* @package KiviCare_API\Tests\Contract
|
||||
*/
|
||||
|
||||
/**
|
||||
* Appointment endpoints contract tests.
|
||||
*/
|
||||
class Test_Appointment_Endpoints_Contract extends KiviCare_API_Test_Case {
|
||||
|
||||
/**
|
||||
* Test GET /wp-json/kivicare/v1/appointments endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_get_appointments_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Appointments GET endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Authenticated doctor
|
||||
wp_set_current_user( $this->doctor_user );
|
||||
|
||||
// ACT: Make GET request to appointments endpoint
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/appointments' );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertIsArray( $data );
|
||||
|
||||
// Validate pagination structure
|
||||
if ( ! empty( $data ) ) {
|
||||
$this->assertArrayHasKey( 'data', $data );
|
||||
$this->assertArrayHasKey( 'total', $data );
|
||||
$this->assertArrayHasKey( 'page', $data );
|
||||
$this->assertArrayHasKey( 'per_page', $data );
|
||||
|
||||
// Validate appointment data structure
|
||||
$appointment = $data['data'][0];
|
||||
$this->assertAppointmentStructure( $appointment );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/appointments endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_create_appointment_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Appointments POST endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Valid appointment data
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_data = array(
|
||||
'appointment_start_date' => gmdate( 'Y-m-d', strtotime( '+1 day' ) ),
|
||||
'appointment_start_time' => '14:30:00',
|
||||
'appointment_end_date' => gmdate( 'Y-m-d', strtotime( '+1 day' ) ),
|
||||
'appointment_end_time' => '15:00:00',
|
||||
'doctor_id' => $this->doctor_user,
|
||||
'patient_id' => $this->patient_user,
|
||||
'clinic_id' => $clinic_id,
|
||||
'visit_type' => 'consultation',
|
||||
'description' => 'Consulta de rotina',
|
||||
);
|
||||
|
||||
// ACT: Make POST request as receptionist
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/appointments', 'POST', $appointment_data, $this->receptionist_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 201 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertAppointmentStructure( $data );
|
||||
$this->assertEquals( $appointment_data['doctor_id'], $data['doctor_id'] );
|
||||
$this->assertEquals( $appointment_data['patient_id'], $data['patient_id'] );
|
||||
$this->assertIsInt( $data['id'] );
|
||||
$this->assertGreaterThan( 0, $data['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/appointments with scheduling conflict.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_create_appointment_time_conflict() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Appointment time conflict validation not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing appointment and conflicting data
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$existing_appointment = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
$conflicting_data = array(
|
||||
'appointment_start_date' => gmdate( 'Y-m-d', strtotime( '+1 day' ) ),
|
||||
'appointment_start_time' => '14:45:00', // Conflicts with existing 14:30-15:00
|
||||
'appointment_end_date' => gmdate( 'Y-m-d', strtotime( '+1 day' ) ),
|
||||
'appointment_end_time' => '15:15:00',
|
||||
'doctor_id' => $this->doctor_user,
|
||||
'patient_id' => $this->factory->user->create( array( 'role' => 'patient' ) ),
|
||||
'clinic_id' => $clinic_id,
|
||||
'visit_type' => 'consultation',
|
||||
);
|
||||
|
||||
// ACT: Make POST request with conflicting time
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/appointments', 'POST', $conflicting_data, $this->receptionist_user );
|
||||
|
||||
// ASSERT: Time conflict error contract
|
||||
$this->assertRestResponse( $response, 409 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'code', $data );
|
||||
$this->assertEquals( 'appointment_time_conflict', $data['code'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /wp-json/kivicare/v1/appointments/{id} endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_get_appointment_by_id_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Appointment by ID endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing appointment
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
// ACT: Make GET request for specific appointment
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/appointments/{$appointment_id}", 'GET', array(), $this->doctor_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertAppointmentStructure( $data );
|
||||
$this->assertEquals( $appointment_id, $data['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test PUT /wp-json/kivicare/v1/appointments/{id} endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_update_appointment_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Appointment PUT endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing appointment and update data
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$update_data = array(
|
||||
'appointment_start_time' => '15:30:00',
|
||||
'appointment_end_time' => '16:00:00',
|
||||
'description' => 'Updated appointment description',
|
||||
);
|
||||
|
||||
// ACT: Make PUT request to update appointment
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/appointments/{$appointment_id}", 'PUT', $update_data, $this->receptionist_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertAppointmentStructure( $data );
|
||||
$this->assertEquals( $update_data['appointment_start_time'], $data['appointment_start_time'] );
|
||||
$this->assertEquals( $update_data['description'], $data['description'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test DELETE /wp-json/kivicare/v1/appointments/{id} endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_delete_appointment_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Appointment DELETE endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing appointment
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
// ACT: Make DELETE request
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/appointments/{$appointment_id}", 'DELETE', array(), $this->receptionist_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'deleted', $data );
|
||||
$this->assertTrue( $data['deleted'] );
|
||||
$this->assertEquals( $appointment_id, $data['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /wp-json/kivicare/v1/appointments/available-slots endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_get_available_slots_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Available slots endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Query parameters for slot availability
|
||||
$query_params = array(
|
||||
'doctor_id' => $this->doctor_user,
|
||||
'date' => gmdate( 'Y-m-d', strtotime( '+1 day' ) ),
|
||||
'clinic_id' => $this->create_test_clinic(),
|
||||
);
|
||||
|
||||
// ACT: Make GET request for available slots
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/appointments/available-slots', 'GET', $query_params );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertIsArray( $data );
|
||||
$this->assertArrayHasKey( 'date', $data );
|
||||
$this->assertArrayHasKey( 'doctor_id', $data );
|
||||
$this->assertArrayHasKey( 'available_slots', $data );
|
||||
|
||||
// Validate slot structure
|
||||
if ( ! empty( $data['available_slots'] ) ) {
|
||||
$slot = $data['available_slots'][0];
|
||||
$this->assertArrayHasKey( 'start_time', $slot );
|
||||
$this->assertArrayHasKey( 'end_time', $slot );
|
||||
$this->assertArrayHasKey( 'available', $slot );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test appointment filtering and search capabilities.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_appointment_filtering_contract() {
|
||||
// This test will fail initially as filtering isn't implemented
|
||||
$this->markTestIncomplete( 'Appointment filtering not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Multiple appointments with different attributes
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
|
||||
// ACT: Test date filtering
|
||||
$filter_params = array(
|
||||
'start_date' => gmdate( 'Y-m-d' ),
|
||||
'end_date' => gmdate( 'Y-m-d', strtotime( '+7 days' ) ),
|
||||
);
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/appointments', 'GET', $filter_params, $this->doctor_user );
|
||||
|
||||
// ASSERT: Filtered response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
// ACT: Test doctor filtering
|
||||
$filter_params = array( 'doctor_id' => $this->doctor_user );
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/appointments', 'GET', $filter_params, $this->admin_user );
|
||||
|
||||
// ASSERT: Doctor-filtered response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to assert appointment data structure.
|
||||
*
|
||||
* @param array $appointment Appointment data to validate.
|
||||
*/
|
||||
private function assertAppointmentStructure( $appointment ) {
|
||||
$this->assertIsArray( $appointment );
|
||||
|
||||
// Required fields
|
||||
$expected_fields = array(
|
||||
'id', 'appointment_start_date', 'appointment_start_time',
|
||||
'appointment_end_date', 'appointment_end_time', 'doctor_id',
|
||||
'patient_id', 'clinic_id', 'status', 'visit_type', 'created_at'
|
||||
);
|
||||
|
||||
foreach ( $expected_fields as $field ) {
|
||||
$this->assertArrayHasKey( $field, $appointment );
|
||||
}
|
||||
|
||||
// Data type validations
|
||||
$this->assertIsInt( $appointment['id'] );
|
||||
$this->assertIsInt( $appointment['doctor_id'] );
|
||||
$this->assertIsInt( $appointment['patient_id'] );
|
||||
$this->assertIsInt( $appointment['clinic_id'] );
|
||||
$this->assertIsInt( $appointment['status'] );
|
||||
|
||||
// Date/time format validations
|
||||
$this->assertMatchesRegularExpression( '/^\d{4}-\d{2}-\d{2}$/', $appointment['appointment_start_date'] );
|
||||
$this->assertMatchesRegularExpression( '/^\d{2}:\d{2}:\d{2}$/', $appointment['appointment_start_time'] );
|
||||
|
||||
// Optional fields that might be present
|
||||
$optional_fields = array( 'description', 'patient', 'doctor', 'clinic' );
|
||||
|
||||
// If expanded data is included, validate structure
|
||||
if ( isset( $appointment['patient'] ) ) {
|
||||
$this->assertIsArray( $appointment['patient'] );
|
||||
$this->assertArrayHasKey( 'display_name', $appointment['patient'] );
|
||||
}
|
||||
|
||||
if ( isset( $appointment['doctor'] ) ) {
|
||||
$this->assertIsArray( $appointment['doctor'] );
|
||||
$this->assertArrayHasKey( 'display_name', $appointment['doctor'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
194
tests/contract/test-auth-endpoints.php
Normal file
194
tests/contract/test-auth-endpoints.php
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Contract tests for Authentication endpoints.
|
||||
*
|
||||
* These tests define the API contract and MUST FAIL initially (TDD RED phase).
|
||||
*
|
||||
* @package KiviCare_API\Tests\Contract
|
||||
*/
|
||||
|
||||
/**
|
||||
* Authentication endpoints contract tests.
|
||||
*/
|
||||
class Test_Auth_Endpoints_Contract extends KiviCare_API_Test_Case {
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/auth/login endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_auth_login_endpoint_contract() {
|
||||
// ARRANGE: Valid login credentials
|
||||
$login_data = array(
|
||||
'username' => 'test_doctor',
|
||||
'password' => 'password123',
|
||||
);
|
||||
|
||||
// ACT: Make POST request to login endpoint
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/auth/login', 'POST', $login_data );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'token', $data );
|
||||
$this->assertArrayHasKey( 'user_id', $data );
|
||||
$this->assertArrayHasKey( 'role', $data );
|
||||
$this->assertArrayHasKey( 'expires_in', $data );
|
||||
|
||||
// Validate token format (JWT)
|
||||
$this->assertIsString( $data['token'] );
|
||||
$this->assertMatchesRegularExpression( '/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/', $data['token'] );
|
||||
|
||||
// Validate user data
|
||||
$this->assertIsInt( $data['user_id'] );
|
||||
$this->assertGreaterThan( 0, $data['user_id'] );
|
||||
$this->assertIsString( $data['role'] );
|
||||
$this->assertContains( $data['role'], array( 'administrator', 'doctor', 'patient', 'kivicare_receptionist' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/auth/login with invalid credentials.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_auth_login_invalid_credentials() {
|
||||
// ARRANGE: Invalid credentials
|
||||
$invalid_data = array(
|
||||
'username' => 'nonexistent_user',
|
||||
'password' => 'wrong_password',
|
||||
);
|
||||
|
||||
// ACT: Make POST request with invalid data
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/auth/login', 'POST', $invalid_data );
|
||||
|
||||
// ASSERT: Error response contract
|
||||
$this->assertRestResponse( $response, 401 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'code', $data );
|
||||
$this->assertArrayHasKey( 'message', $data );
|
||||
$this->assertEquals( 'invalid_credentials', $data['code'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/auth/login with missing fields.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_auth_login_missing_fields() {
|
||||
// ARRANGE: Missing username
|
||||
$incomplete_data = array(
|
||||
'password' => 'password123',
|
||||
);
|
||||
|
||||
// ACT: Make POST request with incomplete data
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/auth/login', 'POST', $incomplete_data );
|
||||
|
||||
// ASSERT: Validation error contract
|
||||
$this->assertRestResponse( $response, 400 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'code', $data );
|
||||
$this->assertEquals( 'rest_missing_callback_param', $data['code'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/auth/refresh endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_auth_refresh_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Refresh endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Valid refresh token (will be implemented)
|
||||
$refresh_data = array(
|
||||
'refresh_token' => 'valid_refresh_token_here',
|
||||
);
|
||||
|
||||
// ACT: Make POST request to refresh endpoint
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/auth/refresh', 'POST', $refresh_data );
|
||||
|
||||
// ASSERT: Response contract (will fail until implemented)
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'token', $data );
|
||||
$this->assertArrayHasKey( 'expires_in', $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/auth/logout endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_auth_logout_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Logout endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Authenticated user
|
||||
wp_set_current_user( $this->doctor_user );
|
||||
|
||||
// ACT: Make POST request to logout endpoint
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/auth/logout', 'POST' );
|
||||
|
||||
// ASSERT: Response contract (will fail until implemented)
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'message', $data );
|
||||
$this->assertEquals( 'Logout successful', $data['message'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test authentication middleware with invalid token.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_invalid_token_response_contract() {
|
||||
// This test will fail initially as JWT authentication isn't implemented
|
||||
$this->markTestIncomplete( 'JWT authentication not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Invalid JWT token
|
||||
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer invalid_token_here';
|
||||
|
||||
// ACT: Try to access protected endpoint
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/patients' );
|
||||
|
||||
// ASSERT: Authentication error contract
|
||||
$this->assertRestResponse( $response, 401 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'code', $data );
|
||||
$this->assertEquals( 'rest_forbidden', $data['code'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test authentication middleware with expired token.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_expired_token_response_contract() {
|
||||
// This test will fail initially as JWT authentication isn't implemented
|
||||
$this->markTestIncomplete( 'JWT authentication not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Expired JWT token
|
||||
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer expired_token_here';
|
||||
|
||||
// ACT: Try to access protected endpoint
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/patients' );
|
||||
|
||||
// ASSERT: Token expiry error contract
|
||||
$this->assertRestResponse( $response, 401 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'code', $data );
|
||||
$this->assertEquals( 'jwt_auth_token_expired', $data['code'] );
|
||||
}
|
||||
}
|
||||
251
tests/contract/test-clinic-endpoints.php
Normal file
251
tests/contract/test-clinic-endpoints.php
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Contract tests for Clinic endpoints.
|
||||
*
|
||||
* These tests define the API contract and MUST FAIL initially (TDD RED phase).
|
||||
*
|
||||
* @package KiviCare_API\Tests\Contract
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clinic endpoints contract tests.
|
||||
*/
|
||||
class Test_Clinic_Endpoints_Contract extends KiviCare_API_Test_Case {
|
||||
|
||||
/**
|
||||
* Test GET /wp-json/kivicare/v1/clinics endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_get_clinics_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Clinics GET endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Authenticated administrator
|
||||
wp_set_current_user( $this->admin_user );
|
||||
|
||||
// ACT: Make GET request to clinics endpoint
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/clinics' );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertIsArray( $data );
|
||||
|
||||
// Validate pagination structure
|
||||
if ( ! empty( $data ) ) {
|
||||
$this->assertArrayHasKey( 'data', $data );
|
||||
$this->assertArrayHasKey( 'total', $data );
|
||||
$this->assertArrayHasKey( 'page', $data );
|
||||
$this->assertArrayHasKey( 'per_page', $data );
|
||||
|
||||
// Validate clinic data structure
|
||||
$clinic = $data['data'][0];
|
||||
$this->assertClinicStructure( $clinic );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/clinics endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_create_clinic_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Clinics POST endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Valid clinic data
|
||||
$clinic_data = array(
|
||||
'name' => 'Nova Clínica',
|
||||
'email' => 'nova@clinica.com',
|
||||
'telephone_no' => '+351987654321',
|
||||
'address' => 'Rua Nova, 456',
|
||||
'city' => 'Porto',
|
||||
'state' => 'Porto',
|
||||
'country' => 'Portugal',
|
||||
'postal_code' => '4000-001',
|
||||
'specialties' => 'Cardiology,Neurology',
|
||||
);
|
||||
|
||||
// ACT: Make POST request as administrator
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/clinics', 'POST', $clinic_data, $this->admin_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 201 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertClinicStructure( $data );
|
||||
$this->assertEquals( $clinic_data['name'], $data['name'] );
|
||||
$this->assertEquals( $clinic_data['email'], $data['email'] );
|
||||
$this->assertIsInt( $data['id'] );
|
||||
$this->assertGreaterThan( 0, $data['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/clinics with invalid data.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_create_clinic_invalid_data() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Clinics POST validation not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Invalid clinic data (missing required fields)
|
||||
$invalid_data = array(
|
||||
'name' => '', // Empty name should fail
|
||||
'email' => 'invalid-email', // Invalid email format
|
||||
);
|
||||
|
||||
// ACT: Make POST request with invalid data
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/clinics', 'POST', $invalid_data, $this->admin_user );
|
||||
|
||||
// ASSERT: Validation error contract
|
||||
$this->assertRestResponse( $response, 400 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'code', $data );
|
||||
$this->assertEquals( 'rest_invalid_param', $data['code'] );
|
||||
$this->assertArrayHasKey( 'data', $data );
|
||||
$this->assertArrayHasKey( 'params', $data['data'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /wp-json/kivicare/v1/clinics/{id} endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_get_clinic_by_id_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Clinic by ID endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing clinic
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
|
||||
// ACT: Make GET request for specific clinic
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/clinics/{$clinic_id}", 'GET', array(), $this->admin_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertClinicStructure( $data );
|
||||
$this->assertEquals( $clinic_id, $data['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test PUT /wp-json/kivicare/v1/clinics/{id} endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_update_clinic_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Clinic PUT endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing clinic and update data
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$update_data = array(
|
||||
'name' => 'Clínica Atualizada',
|
||||
'telephone_no' => '+351111222333',
|
||||
);
|
||||
|
||||
// ACT: Make PUT request to update clinic
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/clinics/{$clinic_id}", 'PUT', $update_data, $this->admin_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertClinicStructure( $data );
|
||||
$this->assertEquals( $update_data['name'], $data['name'] );
|
||||
$this->assertEquals( $update_data['telephone_no'], $data['telephone_no'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test DELETE /wp-json/kivicare/v1/clinics/{id} endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_delete_clinic_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Clinic DELETE endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing clinic
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
|
||||
// ACT: Make DELETE request
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/clinics/{$clinic_id}", 'DELETE', array(), $this->admin_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'deleted', $data );
|
||||
$this->assertTrue( $data['deleted'] );
|
||||
$this->assertEquals( $clinic_id, $data['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test clinic permissions for different user roles.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_clinic_permissions_contract() {
|
||||
// This test will fail initially as role-based permissions aren't implemented
|
||||
$this->markTestIncomplete( 'Role-based permissions not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing clinic
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
|
||||
// ACT & ASSERT: Doctor should not be able to create clinics
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/clinics', 'POST', array( 'name' => 'Test' ), $this->doctor_user );
|
||||
$this->assertRestResponse( $response, 403 );
|
||||
|
||||
// ACT & ASSERT: Patient should not be able to access clinics
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/clinics', 'GET', array(), $this->patient_user );
|
||||
$this->assertRestResponse( $response, 403 );
|
||||
|
||||
// ACT & ASSERT: Administrator should have full access
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/clinics/{$clinic_id}", 'GET', array(), $this->admin_user );
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to assert clinic data structure.
|
||||
*
|
||||
* @param array $clinic Clinic data to validate.
|
||||
*/
|
||||
private function assertClinicStructure( $clinic ) {
|
||||
$this->assertIsArray( $clinic );
|
||||
|
||||
// Required fields
|
||||
$this->assertArrayHasKey( 'id', $clinic );
|
||||
$this->assertArrayHasKey( 'name', $clinic );
|
||||
$this->assertArrayHasKey( 'status', $clinic );
|
||||
|
||||
// Optional fields that should be present in response
|
||||
$expected_fields = array(
|
||||
'email', 'telephone_no', 'address', 'city',
|
||||
'state', 'country', 'postal_code', 'specialties',
|
||||
'clinic_admin_id', 'created_at'
|
||||
);
|
||||
|
||||
foreach ( $expected_fields as $field ) {
|
||||
$this->assertArrayHasKey( $field, $clinic );
|
||||
}
|
||||
|
||||
// Data type validations
|
||||
$this->assertIsInt( $clinic['id'] );
|
||||
$this->assertIsString( $clinic['name'] );
|
||||
$this->assertIsInt( $clinic['status'] );
|
||||
|
||||
if ( ! empty( $clinic['email'] ) ) {
|
||||
$this->assertIsString( $clinic['email'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
361
tests/contract/test-encounter-endpoints.php
Normal file
361
tests/contract/test-encounter-endpoints.php
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Contract tests for Encounter endpoints.
|
||||
*
|
||||
* These tests define the API contract and MUST FAIL initially (TDD RED phase).
|
||||
*
|
||||
* @package KiviCare_API\Tests\Contract
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encounter endpoints contract tests.
|
||||
*/
|
||||
class Test_Encounter_Endpoints_Contract extends KiviCare_API_Test_Case {
|
||||
|
||||
/**
|
||||
* Test GET /wp-json/kivicare/v1/encounters endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_get_encounters_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Encounters GET endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Authenticated doctor
|
||||
wp_set_current_user( $this->doctor_user );
|
||||
|
||||
// ACT: Make GET request to encounters endpoint
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/encounters' );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertIsArray( $data );
|
||||
|
||||
// Validate pagination structure
|
||||
if ( ! empty( $data ) ) {
|
||||
$this->assertArrayHasKey( 'data', $data );
|
||||
$this->assertArrayHasKey( 'total', $data );
|
||||
$this->assertArrayHasKey( 'page', $data );
|
||||
$this->assertArrayHasKey( 'per_page', $data );
|
||||
|
||||
// Validate encounter data structure
|
||||
$encounter = $data['data'][0];
|
||||
$this->assertEncounterStructure( $encounter );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/encounters endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_create_encounter_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Encounters POST endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Valid encounter data
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
$encounter_data = array(
|
||||
'encounter_date' => gmdate( 'Y-m-d' ),
|
||||
'appointment_id' => $appointment_id,
|
||||
'patient_id' => $this->patient_user,
|
||||
'doctor_id' => $this->doctor_user,
|
||||
'clinic_id' => $clinic_id,
|
||||
'description' => 'Patient presents with mild fever and fatigue. Diagnosed with common cold.',
|
||||
'status' => 1,
|
||||
'chief_complaint' => 'Fever and fatigue',
|
||||
'diagnosis' => 'Common cold (J00)',
|
||||
'treatment_plan' => 'Rest, fluids, symptomatic treatment',
|
||||
);
|
||||
|
||||
// ACT: Make POST request as doctor
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', $encounter_data, $this->doctor_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 201 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertEncounterStructure( $data );
|
||||
$this->assertEquals( $encounter_data['appointment_id'], $data['appointment_id'] );
|
||||
$this->assertEquals( $encounter_data['patient_id'], $data['patient_id'] );
|
||||
$this->assertIsInt( $data['id'] );
|
||||
$this->assertGreaterThan( 0, $data['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/encounters with invalid data.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_create_encounter_invalid_data() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Encounters POST validation not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Invalid encounter data
|
||||
$invalid_data = array(
|
||||
'encounter_date' => 'invalid-date',
|
||||
'appointment_id' => 'not_a_number',
|
||||
'description' => '', // Empty description should fail
|
||||
);
|
||||
|
||||
// ACT: Make POST request with invalid data
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', $invalid_data, $this->doctor_user );
|
||||
|
||||
// ASSERT: Validation error contract
|
||||
$this->assertRestResponse( $response, 400 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'code', $data );
|
||||
$this->assertEquals( 'rest_invalid_param', $data['code'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /wp-json/kivicare/v1/encounters/{id} endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_get_encounter_by_id_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Encounter by ID endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing encounter
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$encounter_id = $this->create_test_encounter( $appointment_id );
|
||||
|
||||
// ACT: Make GET request for specific encounter
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}", 'GET', array(), $this->doctor_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertEncounterStructure( $data );
|
||||
$this->assertEquals( $encounter_id, $data['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test PUT /wp-json/kivicare/v1/encounters/{id} endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_update_encounter_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Encounter PUT endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing encounter and update data
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$encounter_id = $this->create_test_encounter( $appointment_id );
|
||||
|
||||
$update_data = array(
|
||||
'description' => 'Updated encounter notes with additional observations.',
|
||||
'diagnosis' => 'Viral upper respiratory infection (J06.9)',
|
||||
'treatment_plan' => 'Updated treatment plan with additional recommendations.',
|
||||
'status' => 1,
|
||||
);
|
||||
|
||||
// ACT: Make PUT request to update encounter
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}", 'PUT', $update_data, $this->doctor_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertEncounterStructure( $data );
|
||||
$this->assertEquals( $update_data['description'], $data['description'] );
|
||||
$this->assertEquals( $update_data['diagnosis'], $data['diagnosis'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /wp-json/kivicare/v1/encounters/{id}/prescriptions endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_get_encounter_prescriptions_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Encounter prescriptions endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Encounter with prescriptions
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$encounter_id = $this->create_test_encounter( $appointment_id );
|
||||
|
||||
// ACT: Make GET request for encounter prescriptions
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}/prescriptions", 'GET', array(), $this->doctor_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertIsArray( $data );
|
||||
|
||||
if ( ! empty( $data ) ) {
|
||||
$prescription = $data[0];
|
||||
$this->assertPrescriptionStructure( $prescription );
|
||||
$this->assertEquals( $encounter_id, $prescription['encounter_id'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test medical encounter workflow integration.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_encounter_workflow_contract() {
|
||||
// This test will fail initially as the workflow isn't implemented
|
||||
$this->markTestIncomplete( 'Encounter workflow not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Complete appointment to encounter workflow
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
// ACT: Create encounter from appointment
|
||||
$encounter_data = array(
|
||||
'appointment_id' => $appointment_id,
|
||||
'description' => 'Patient consultation completed successfully.',
|
||||
'status' => 1,
|
||||
);
|
||||
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', $encounter_data, $this->doctor_user );
|
||||
|
||||
// ASSERT: Encounter creation triggers appointment status update
|
||||
$this->assertRestResponse( $response, 201 );
|
||||
|
||||
$encounter = $response->get_data();
|
||||
$this->assertEncounterStructure( $encounter );
|
||||
|
||||
// Verify appointment status was updated
|
||||
$appointment_response = $this->make_request( "/wp-json/kivicare/v1/appointments/{$appointment_id}", 'GET', array(), $this->doctor_user );
|
||||
$appointment = $appointment_response->get_data();
|
||||
$this->assertEquals( 'completed', $appointment['status'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test encounter access permissions by role.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_encounter_permissions_contract() {
|
||||
// This test will fail initially as permissions aren't implemented
|
||||
$this->markTestIncomplete( 'Encounter permissions not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Encounter created by doctor
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$encounter_id = $this->create_test_encounter( $appointment_id );
|
||||
|
||||
// ACT & ASSERT: Patient should be able to view their encounters (read-only)
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}", 'GET', array(), $this->patient_user );
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
// ACT & ASSERT: Patient should not be able to modify encounters
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}", 'PUT', array( 'description' => 'Hacked' ), $this->patient_user );
|
||||
$this->assertRestResponse( $response, 403 );
|
||||
|
||||
// ACT & ASSERT: Receptionist should not access medical encounters
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}", 'GET', array(), $this->receptionist_user );
|
||||
$this->assertRestResponse( $response, 403 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create test encounter.
|
||||
*
|
||||
* @param int $appointment_id Appointment ID.
|
||||
* @return int Encounter ID.
|
||||
*/
|
||||
private function create_test_encounter( $appointment_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$encounter_data = array(
|
||||
'encounter_date' => gmdate( 'Y-m-d' ),
|
||||
'clinic_id' => get_option( 'kivicare_api_test_clinic_id', 1 ),
|
||||
'doctor_id' => $this->doctor_user,
|
||||
'patient_id' => $this->patient_user,
|
||||
'appointment_id' => $appointment_id,
|
||||
'description' => 'Test medical encounter',
|
||||
'status' => 1,
|
||||
'added_by' => $this->doctor_user,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
);
|
||||
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_patient_encounters', $encounter_data );
|
||||
return $wpdb->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to assert encounter data structure.
|
||||
*
|
||||
* @param array $encounter Encounter data to validate.
|
||||
*/
|
||||
private function assertEncounterStructure( $encounter ) {
|
||||
$this->assertIsArray( $encounter );
|
||||
|
||||
// Required fields
|
||||
$expected_fields = array(
|
||||
'id', 'encounter_date', 'patient_id', 'doctor_id',
|
||||
'clinic_id', 'appointment_id', 'description', 'status', 'created_at'
|
||||
);
|
||||
|
||||
foreach ( $expected_fields as $field ) {
|
||||
$this->assertArrayHasKey( $field, $encounter );
|
||||
}
|
||||
|
||||
// Data type validations
|
||||
$this->assertIsInt( $encounter['id'] );
|
||||
$this->assertIsInt( $encounter['patient_id'] );
|
||||
$this->assertIsInt( $encounter['doctor_id'] );
|
||||
$this->assertIsInt( $encounter['clinic_id'] );
|
||||
$this->assertIsInt( $encounter['status'] );
|
||||
|
||||
// Date format validation
|
||||
$this->assertMatchesRegularExpression( '/^\d{4}-\d{2}-\d{2}$/', $encounter['encounter_date'] );
|
||||
|
||||
// Optional expanded data
|
||||
if ( isset( $encounter['patient'] ) ) {
|
||||
$this->assertIsArray( $encounter['patient'] );
|
||||
$this->assertArrayHasKey( 'display_name', $encounter['patient'] );
|
||||
}
|
||||
|
||||
if ( isset( $encounter['doctor'] ) ) {
|
||||
$this->assertIsArray( $encounter['doctor'] );
|
||||
$this->assertArrayHasKey( 'display_name', $encounter['doctor'] );
|
||||
}
|
||||
|
||||
if ( isset( $encounter['prescriptions'] ) ) {
|
||||
$this->assertIsArray( $encounter['prescriptions'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to assert prescription data structure.
|
||||
*
|
||||
* @param array $prescription Prescription data to validate.
|
||||
*/
|
||||
private function assertPrescriptionStructure( $prescription ) {
|
||||
$this->assertIsArray( $prescription );
|
||||
|
||||
$expected_fields = array(
|
||||
'id', 'encounter_id', 'patient_id', 'name',
|
||||
'frequency', 'duration', 'instruction', 'created_at'
|
||||
);
|
||||
|
||||
foreach ( $expected_fields as $field ) {
|
||||
$this->assertArrayHasKey( $field, $prescription );
|
||||
}
|
||||
|
||||
$this->assertIsInt( $prescription['id'] );
|
||||
$this->assertIsInt( $prescription['encounter_id'] );
|
||||
$this->assertIsInt( $prescription['patient_id'] );
|
||||
$this->assertIsString( $prescription['name'] );
|
||||
}
|
||||
}
|
||||
328
tests/contract/test-patient-endpoints.php
Normal file
328
tests/contract/test-patient-endpoints.php
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Contract tests for Patient endpoints.
|
||||
*
|
||||
* These tests define the API contract and MUST FAIL initially (TDD RED phase).
|
||||
*
|
||||
* @package KiviCare_API\Tests\Contract
|
||||
*/
|
||||
|
||||
/**
|
||||
* Patient endpoints contract tests.
|
||||
*/
|
||||
class Test_Patient_Endpoints_Contract extends KiviCare_API_Test_Case {
|
||||
|
||||
/**
|
||||
* Test GET /wp-json/kivicare/v1/patients endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_get_patients_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Patients GET endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Authenticated doctor
|
||||
wp_set_current_user( $this->doctor_user );
|
||||
|
||||
// ACT: Make GET request to patients endpoint
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/patients' );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertIsArray( $data );
|
||||
|
||||
// Validate pagination structure
|
||||
if ( ! empty( $data ) ) {
|
||||
$this->assertArrayHasKey( 'data', $data );
|
||||
$this->assertArrayHasKey( 'total', $data );
|
||||
$this->assertArrayHasKey( 'page', $data );
|
||||
$this->assertArrayHasKey( 'per_page', $data );
|
||||
|
||||
// Validate patient data structure
|
||||
$patient = $data['data'][0];
|
||||
$this->assertPatientStructure( $patient );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/patients endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_create_patient_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Patients POST endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Valid patient data
|
||||
$patient_data = array(
|
||||
'display_name' => 'João Silva Santos',
|
||||
'user_email' => 'joao.santos@example.com',
|
||||
'clinic_id' => $this->create_test_clinic(),
|
||||
'first_name' => 'João',
|
||||
'last_name' => 'Santos',
|
||||
'phone' => '+351912345678',
|
||||
'address' => 'Rua das Flores, 123',
|
||||
'city' => 'Lisboa',
|
||||
'postal_code' => '1000-001',
|
||||
'birth_date' => '1985-05-15',
|
||||
'gender' => 'M',
|
||||
);
|
||||
|
||||
// ACT: Make POST request as doctor
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/patients', 'POST', $patient_data, $this->doctor_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 201 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertPatientStructure( $data );
|
||||
$this->assertEquals( $patient_data['display_name'], $data['display_name'] );
|
||||
$this->assertEquals( $patient_data['user_email'], $data['user_email'] );
|
||||
$this->assertIsInt( $data['id'] );
|
||||
$this->assertGreaterThan( 0, $data['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/patients with invalid data.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_create_patient_invalid_data() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Patients POST validation not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Invalid patient data
|
||||
$invalid_data = array(
|
||||
'display_name' => '', // Empty name should fail
|
||||
'user_email' => 'invalid-email', // Invalid email format
|
||||
'clinic_id' => 'not_a_number', // Invalid clinic ID
|
||||
);
|
||||
|
||||
// ACT: Make POST request with invalid data
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/patients', 'POST', $invalid_data, $this->doctor_user );
|
||||
|
||||
// ASSERT: Validation error contract
|
||||
$this->assertRestResponse( $response, 400 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'code', $data );
|
||||
$this->assertEquals( 'rest_invalid_param', $data['code'] );
|
||||
$this->assertArrayHasKey( 'data', $data );
|
||||
$this->assertArrayHasKey( 'params', $data['data'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /wp-json/kivicare/v1/patients/{id} endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_get_patient_by_id_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Patient by ID endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing patient
|
||||
$patient_id = $this->patient_user;
|
||||
|
||||
// ACT: Make GET request for specific patient
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/patients/{$patient_id}", 'GET', array(), $this->doctor_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertPatientStructure( $data );
|
||||
$this->assertEquals( $patient_id, $data['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test PUT /wp-json/kivicare/v1/patients/{id} endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_update_patient_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Patient PUT endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing patient and update data
|
||||
$patient_id = $this->patient_user;
|
||||
$update_data = array(
|
||||
'phone' => '+351987654321',
|
||||
'address' => 'Nova Morada, 456',
|
||||
);
|
||||
|
||||
// ACT: Make PUT request to update patient
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/patients/{$patient_id}", 'PUT', $update_data, $this->doctor_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertPatientStructure( $data );
|
||||
$this->assertEquals( $update_data['phone'], $data['phone'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /wp-json/kivicare/v1/patients/{id}/encounters endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_get_patient_encounters_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Patient encounters endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Patient with encounters
|
||||
$patient_id = $this->patient_user;
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $patient_id );
|
||||
|
||||
// ACT: Make GET request for patient encounters
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/patients/{$patient_id}/encounters", 'GET', array(), $this->doctor_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertIsArray( $data );
|
||||
|
||||
if ( ! empty( $data ) ) {
|
||||
$encounter = $data[0];
|
||||
$this->assertEncounterStructure( $encounter );
|
||||
$this->assertEquals( $patient_id, $encounter['patient_id'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /wp-json/kivicare/v1/patients/{id}/prescriptions endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_get_patient_prescriptions_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Patient prescriptions endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Patient with prescriptions
|
||||
$patient_id = $this->patient_user;
|
||||
|
||||
// ACT: Make GET request for patient prescriptions
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/patients/{$patient_id}/prescriptions", 'GET', array(), $this->doctor_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertIsArray( $data );
|
||||
|
||||
if ( ! empty( $data ) ) {
|
||||
$prescription = $data[0];
|
||||
$this->assertPrescriptionStructure( $prescription );
|
||||
$this->assertEquals( $patient_id, $prescription['patient_id'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test patient privacy and data access restrictions.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_patient_privacy_contract() {
|
||||
// This test will fail initially as privacy controls aren't implemented
|
||||
$this->markTestIncomplete( 'Patient privacy controls not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Two different patients
|
||||
$patient1_id = $this->patient_user;
|
||||
$patient2_id = $this->factory->user->create( array( 'role' => 'patient' ) );
|
||||
|
||||
// ACT & ASSERT: Patient should only see their own data
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/patients/{$patient1_id}", 'GET', array(), $patient1_id );
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
// ACT & ASSERT: Patient should not see other patient's data
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/patients/{$patient2_id}", 'GET', array(), $patient1_id );
|
||||
$this->assertRestResponse( $response, 403 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to assert patient data structure.
|
||||
*
|
||||
* @param array $patient Patient data to validate.
|
||||
*/
|
||||
private function assertPatientStructure( $patient ) {
|
||||
$this->assertIsArray( $patient );
|
||||
|
||||
// Required fields from wp_users
|
||||
$this->assertArrayHasKey( 'id', $patient );
|
||||
$this->assertArrayHasKey( 'display_name', $patient );
|
||||
$this->assertArrayHasKey( 'user_email', $patient );
|
||||
|
||||
// Additional patient fields
|
||||
$expected_fields = array(
|
||||
'first_name', 'last_name', 'phone', 'address',
|
||||
'city', 'postal_code', 'birth_date', 'gender',
|
||||
'clinic_id', 'registration_date'
|
||||
);
|
||||
|
||||
foreach ( $expected_fields as $field ) {
|
||||
$this->assertArrayHasKey( $field, $patient );
|
||||
}
|
||||
|
||||
// Data type validations
|
||||
$this->assertIsInt( $patient['id'] );
|
||||
$this->assertIsString( $patient['display_name'] );
|
||||
$this->assertIsString( $patient['user_email'] );
|
||||
|
||||
if ( ! empty( $patient['clinic_id'] ) ) {
|
||||
$this->assertIsInt( $patient['clinic_id'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to assert encounter data structure.
|
||||
*
|
||||
* @param array $encounter Encounter data to validate.
|
||||
*/
|
||||
private function assertEncounterStructure( $encounter ) {
|
||||
$this->assertIsArray( $encounter );
|
||||
|
||||
$expected_fields = array(
|
||||
'id', 'encounter_date', 'patient_id', 'doctor_id',
|
||||
'clinic_id', 'appointment_id', 'description', 'status'
|
||||
);
|
||||
|
||||
foreach ( $expected_fields as $field ) {
|
||||
$this->assertArrayHasKey( $field, $encounter );
|
||||
}
|
||||
|
||||
$this->assertIsInt( $encounter['id'] );
|
||||
$this->assertIsInt( $encounter['patient_id'] );
|
||||
$this->assertIsInt( $encounter['doctor_id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to assert prescription data structure.
|
||||
*
|
||||
* @param array $prescription Prescription data to validate.
|
||||
*/
|
||||
private function assertPrescriptionStructure( $prescription ) {
|
||||
$this->assertIsArray( $prescription );
|
||||
|
||||
$expected_fields = array(
|
||||
'id', 'encounter_id', 'patient_id', 'name',
|
||||
'frequency', 'duration', 'instruction'
|
||||
);
|
||||
|
||||
foreach ( $expected_fields as $field ) {
|
||||
$this->assertArrayHasKey( $field, $prescription );
|
||||
}
|
||||
|
||||
$this->assertIsInt( $prescription['id'] );
|
||||
$this->assertIsInt( $prescription['patient_id'] );
|
||||
$this->assertIsString( $prescription['name'] );
|
||||
}
|
||||
}
|
||||
381
tests/contract/test-prescription-endpoints.php
Normal file
381
tests/contract/test-prescription-endpoints.php
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Contract tests for Prescription endpoints.
|
||||
*
|
||||
* These tests define the API contract and MUST FAIL initially (TDD RED phase).
|
||||
*
|
||||
* @package KiviCare_API\Tests\Contract
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prescription endpoints contract tests.
|
||||
*/
|
||||
class Test_Prescription_Endpoints_Contract extends KiviCare_API_Test_Case {
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/encounters/{id}/prescriptions endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_create_prescription_endpoint_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Prescriptions POST endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Valid prescription data
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$encounter_id = $this->create_test_encounter( $appointment_id );
|
||||
|
||||
$prescription_data = array(
|
||||
'name' => 'Paracetamol 500mg',
|
||||
'frequency' => 'Every 8 hours',
|
||||
'duration' => '7 days',
|
||||
'instruction' => 'Take with water after meals. Do not exceed recommended dose.',
|
||||
'dosage' => '1 tablet',
|
||||
'quantity' => '21 tablets',
|
||||
);
|
||||
|
||||
// ACT: Make POST request as doctor
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}/prescriptions", 'POST', $prescription_data, $this->doctor_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 201 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertPrescriptionStructure( $data );
|
||||
$this->assertEquals( $prescription_data['name'], $data['name'] );
|
||||
$this->assertEquals( $prescription_data['frequency'], $data['frequency'] );
|
||||
$this->assertEquals( $encounter_id, $data['encounter_id'] );
|
||||
$this->assertIsInt( $data['id'] );
|
||||
$this->assertGreaterThan( 0, $data['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test POST /wp-json/kivicare/v1/encounters/{id}/prescriptions with invalid data.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_create_prescription_invalid_data() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Prescriptions POST validation not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Invalid prescription data
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$encounter_id = $this->create_test_encounter( $appointment_id );
|
||||
|
||||
$invalid_data = array(
|
||||
'name' => '', // Empty name should fail
|
||||
'frequency' => '', // Empty frequency should fail
|
||||
'duration' => 'invalid duration format',
|
||||
);
|
||||
|
||||
// ACT: Make POST request with invalid data
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}/prescriptions", 'POST', $invalid_data, $this->doctor_user );
|
||||
|
||||
// ASSERT: Validation error contract
|
||||
$this->assertRestResponse( $response, 400 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'code', $data );
|
||||
$this->assertEquals( 'rest_invalid_param', $data['code'] );
|
||||
$this->assertArrayHasKey( 'data', $data );
|
||||
$this->assertArrayHasKey( 'params', $data['data'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test GET /wp-json/kivicare/v1/prescriptions/{id} endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_get_prescription_by_id_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Prescription by ID endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing prescription
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$encounter_id = $this->create_test_encounter( $appointment_id );
|
||||
$prescription_id = $this->create_test_prescription( $encounter_id );
|
||||
|
||||
// ACT: Make GET request for specific prescription
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/prescriptions/{$prescription_id}", 'GET', array(), $this->doctor_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertPrescriptionStructure( $data );
|
||||
$this->assertEquals( $prescription_id, $data['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test PUT /wp-json/kivicare/v1/prescriptions/{id} endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_update_prescription_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Prescription PUT endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing prescription and update data
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$encounter_id = $this->create_test_encounter( $appointment_id );
|
||||
$prescription_id = $this->create_test_prescription( $encounter_id );
|
||||
|
||||
$update_data = array(
|
||||
'frequency' => 'Every 6 hours',
|
||||
'duration' => '10 days',
|
||||
'instruction' => 'Updated instructions: Take with food to avoid stomach irritation.',
|
||||
);
|
||||
|
||||
// ACT: Make PUT request to update prescription
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/prescriptions/{$prescription_id}", 'PUT', $update_data, $this->doctor_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertPrescriptionStructure( $data );
|
||||
$this->assertEquals( $update_data['frequency'], $data['frequency'] );
|
||||
$this->assertEquals( $update_data['duration'], $data['duration'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test DELETE /wp-json/kivicare/v1/prescriptions/{id} endpoint contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_delete_prescription_contract() {
|
||||
// This test will fail initially as the endpoint doesn't exist yet
|
||||
$this->markTestIncomplete( 'Prescription DELETE endpoint not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Existing prescription
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$encounter_id = $this->create_test_encounter( $appointment_id );
|
||||
$prescription_id = $this->create_test_prescription( $encounter_id );
|
||||
|
||||
// ACT: Make DELETE request
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/prescriptions/{$prescription_id}", 'DELETE', array(), $this->doctor_user );
|
||||
|
||||
// ASSERT: Response contract
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'deleted', $data );
|
||||
$this->assertTrue( $data['deleted'] );
|
||||
$this->assertEquals( $prescription_id, $data['id'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test prescription bulk operations contract.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_bulk_prescription_operations_contract() {
|
||||
// This test will fail initially as bulk operations aren't implemented
|
||||
$this->markTestIncomplete( 'Bulk prescription operations not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Multiple prescriptions for an encounter
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$encounter_id = $this->create_test_encounter( $appointment_id );
|
||||
|
||||
$bulk_prescriptions = array(
|
||||
array(
|
||||
'name' => 'Paracetamol 500mg',
|
||||
'frequency' => 'Every 8 hours',
|
||||
'duration' => '7 days',
|
||||
'instruction' => 'Take with water after meals',
|
||||
),
|
||||
array(
|
||||
'name' => 'Ibuprofen 400mg',
|
||||
'frequency' => 'Every 12 hours',
|
||||
'duration' => '5 days',
|
||||
'instruction' => 'Take with food to prevent stomach upset',
|
||||
),
|
||||
);
|
||||
|
||||
// ACT: Make bulk POST request
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}/prescriptions/bulk", 'POST', array( 'prescriptions' => $bulk_prescriptions ), $this->doctor_user );
|
||||
|
||||
// ASSERT: Bulk response contract
|
||||
$this->assertRestResponse( $response, 201 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertIsArray( $data );
|
||||
$this->assertArrayHasKey( 'created', $data );
|
||||
$this->assertCount( 2, $data['created'] );
|
||||
|
||||
foreach ( $data['created'] as $prescription ) {
|
||||
$this->assertPrescriptionStructure( $prescription );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test prescription permissions by role.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_prescription_permissions_contract() {
|
||||
// This test will fail initially as permissions aren't implemented
|
||||
$this->markTestIncomplete( 'Prescription permissions not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Prescription created by doctor
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$encounter_id = $this->create_test_encounter( $appointment_id );
|
||||
$prescription_id = $this->create_test_prescription( $encounter_id );
|
||||
|
||||
// ACT & ASSERT: Only doctors should be able to create prescriptions
|
||||
$prescription_data = array(
|
||||
'name' => 'Test Medicine',
|
||||
'frequency' => 'Daily',
|
||||
'duration' => '5 days',
|
||||
);
|
||||
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}/prescriptions", 'POST', $prescription_data, $this->patient_user );
|
||||
$this->assertRestResponse( $response, 403 );
|
||||
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}/prescriptions", 'POST', $prescription_data, $this->receptionist_user );
|
||||
$this->assertRestResponse( $response, 403 );
|
||||
|
||||
// ACT & ASSERT: Patients should be able to view their prescriptions (read-only)
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/prescriptions/{$prescription_id}", 'GET', array(), $this->patient_user );
|
||||
$this->assertRestResponse( $response, 200 );
|
||||
|
||||
// ACT & ASSERT: Patients should not be able to modify prescriptions
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/prescriptions/{$prescription_id}", 'PUT', array( 'frequency' => 'Hacked' ), $this->patient_user );
|
||||
$this->assertRestResponse( $response, 403 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test prescription drug interaction warnings.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_prescription_drug_interactions_contract() {
|
||||
// This test will fail initially as drug interaction checking isn't implemented
|
||||
$this->markTestIncomplete( 'Drug interaction checking not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Patient with existing prescription
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$encounter_id = $this->create_test_encounter( $appointment_id );
|
||||
|
||||
// Create first prescription
|
||||
$first_prescription = array(
|
||||
'name' => 'Warfarin 5mg',
|
||||
'frequency' => 'Daily',
|
||||
'duration' => '30 days',
|
||||
);
|
||||
$this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}/prescriptions", 'POST', $first_prescription, $this->doctor_user );
|
||||
|
||||
// ACT: Try to add potentially interacting drug
|
||||
$interacting_prescription = array(
|
||||
'name' => 'Aspirin 100mg',
|
||||
'frequency' => 'Daily',
|
||||
'duration' => '7 days',
|
||||
);
|
||||
$response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}/prescriptions", 'POST', $interacting_prescription, $this->doctor_user );
|
||||
|
||||
// ASSERT: Should return warning but allow prescription
|
||||
$this->assertRestResponse( $response, 201 );
|
||||
|
||||
$data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'warnings', $data );
|
||||
$this->assertArrayHasKey( 'drug_interactions', $data['warnings'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create test encounter.
|
||||
*
|
||||
* @param int $appointment_id Appointment ID.
|
||||
* @return int Encounter ID.
|
||||
*/
|
||||
private function create_test_encounter( $appointment_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$encounter_data = array(
|
||||
'encounter_date' => gmdate( 'Y-m-d' ),
|
||||
'clinic_id' => get_option( 'kivicare_api_test_clinic_id', 1 ),
|
||||
'doctor_id' => $this->doctor_user,
|
||||
'patient_id' => $this->patient_user,
|
||||
'appointment_id' => $appointment_id,
|
||||
'description' => 'Test medical encounter',
|
||||
'status' => 1,
|
||||
'added_by' => $this->doctor_user,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
);
|
||||
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_patient_encounters', $encounter_data );
|
||||
return $wpdb->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create test prescription.
|
||||
*
|
||||
* @param int $encounter_id Encounter ID.
|
||||
* @return int Prescription ID.
|
||||
*/
|
||||
private function create_test_prescription( $encounter_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$prescription_data = array(
|
||||
'encounter_id' => $encounter_id,
|
||||
'patient_id' => $this->patient_user,
|
||||
'name' => 'Test Medicine 100mg',
|
||||
'frequency' => 'Every 8 hours',
|
||||
'duration' => '7 days',
|
||||
'instruction' => 'Take with water',
|
||||
'added_by' => $this->doctor_user,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
);
|
||||
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_prescription', $prescription_data );
|
||||
return $wpdb->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to assert prescription data structure.
|
||||
*
|
||||
* @param array $prescription Prescription data to validate.
|
||||
*/
|
||||
private function assertPrescriptionStructure( $prescription ) {
|
||||
$this->assertIsArray( $prescription );
|
||||
|
||||
$expected_fields = array(
|
||||
'id', 'encounter_id', 'patient_id', 'name',
|
||||
'frequency', 'duration', 'instruction', 'added_by', 'created_at'
|
||||
);
|
||||
|
||||
foreach ( $expected_fields as $field ) {
|
||||
$this->assertArrayHasKey( $field, $prescription );
|
||||
}
|
||||
|
||||
// Data type validations
|
||||
$this->assertIsInt( $prescription['id'] );
|
||||
$this->assertIsInt( $prescription['encounter_id'] );
|
||||
$this->assertIsInt( $prescription['patient_id'] );
|
||||
$this->assertIsString( $prescription['name'] );
|
||||
$this->assertIsString( $prescription['frequency'] );
|
||||
$this->assertIsString( $prescription['duration'] );
|
||||
|
||||
// Optional fields that might be present
|
||||
if ( isset( $prescription['encounter'] ) ) {
|
||||
$this->assertIsArray( $prescription['encounter'] );
|
||||
}
|
||||
|
||||
if ( isset( $prescription['patient'] ) ) {
|
||||
$this->assertIsArray( $prescription['patient'] );
|
||||
$this->assertArrayHasKey( 'display_name', $prescription['patient'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
399
tests/integration/test-billing-automation.php
Normal file
399
tests/integration/test-billing-automation.php
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Integration tests for Automatic Billing Generation (User Story 4).
|
||||
*
|
||||
* These tests validate complete user stories and MUST FAIL initially (TDD RED phase).
|
||||
*
|
||||
* @package KiviCare_API\Tests\Integration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Billing automation integration tests.
|
||||
*
|
||||
* User Story: Automatic billing generation based on encounters and services
|
||||
*/
|
||||
class Test_Billing_Automation extends KiviCare_API_Test_Case {
|
||||
|
||||
/**
|
||||
* Test automatic billing generation workflow.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_automatic_billing_generation_workflow() {
|
||||
// This test will fail initially as billing automation isn't implemented
|
||||
$this->markTestIncomplete( 'Automatic billing generation not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Setup complete billing scenario
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// Create services with prices
|
||||
$service_ids = array();
|
||||
$services = array(
|
||||
array( 'name' => 'General Consultation', 'price' => '75.00', 'type' => 'consultation' ),
|
||||
array( 'name' => 'Blood Pressure Check', 'price' => '15.00', 'type' => 'procedure' ),
|
||||
array( 'name' => 'Prescription Review', 'price' => '25.00', 'type' => 'consultation' ),
|
||||
);
|
||||
|
||||
foreach ( $services as $service ) {
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_services', array(
|
||||
'name' => $service['name'],
|
||||
'price' => $service['price'],
|
||||
'type' => $service['type'],
|
||||
'status' => 1,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
));
|
||||
$service_ids[] = $wpdb->insert_id;
|
||||
}
|
||||
|
||||
// Map doctor and patient to clinic
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_doctor_clinic_mappings', array( 'doctor_id' => $this->doctor_user, 'clinic_id' => $clinic_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_patient_clinic_mappings', array( 'patient_id' => $this->patient_user, 'clinic_id' => $clinic_id ) );
|
||||
|
||||
// STEP 1: Create appointment with services
|
||||
$appointment_data = array(
|
||||
'appointment_start_date' => gmdate( 'Y-m-d', strtotime( '+1 day' ) ),
|
||||
'appointment_start_time' => '14:30:00',
|
||||
'appointment_end_date' => gmdate( 'Y-m-d', strtotime( '+1 day' ) ),
|
||||
'appointment_end_time' => '15:30:00',
|
||||
'doctor_id' => $this->doctor_user,
|
||||
'patient_id' => $this->patient_user,
|
||||
'clinic_id' => $clinic_id,
|
||||
'visit_type' => 'consultation',
|
||||
'services' => array( $service_ids[0], $service_ids[1] ), // Consultation + BP Check
|
||||
);
|
||||
|
||||
$appointment_response = $this->make_request( '/wp-json/kivicare/v1/appointments', 'POST', $appointment_data, $this->receptionist_user );
|
||||
$this->assertRestResponse( $appointment_response, 201 );
|
||||
$appointment_id = $appointment_response->get_data()['id'];
|
||||
|
||||
// Verify service mappings were created
|
||||
$service_mappings = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$wpdb->prefix}kc_appointment_service_mapping WHERE appointment_id = %d", $appointment_id )
|
||||
);
|
||||
$this->assertCount( 2, $service_mappings );
|
||||
|
||||
// STEP 2: Doctor creates encounter (this should trigger billing)
|
||||
$encounter_data = array(
|
||||
'appointment_id' => $appointment_id,
|
||||
'description' => 'Patient consultation completed. Blood pressure checked and found to be within normal range.',
|
||||
'diagnosis' => 'Routine health check - Normal findings',
|
||||
'treatment_plan' => 'Continue current lifestyle, return in 6 months',
|
||||
'status' => 1,
|
||||
);
|
||||
|
||||
$encounter_response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', $encounter_data, $this->doctor_user );
|
||||
$this->assertRestResponse( $encounter_response, 201 );
|
||||
$encounter_id = $encounter_response->get_data()['id'];
|
||||
|
||||
// STEP 3: Verify automatic bill generation
|
||||
$bill = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_bills WHERE encounter_id = %d AND appointment_id = %d",
|
||||
$encounter_id,
|
||||
$appointment_id
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertNotNull( $bill, 'Bill should be automatically generated when encounter is created' );
|
||||
$this->assertEquals( $encounter_id, $bill->encounter_id );
|
||||
$this->assertEquals( $appointment_id, $bill->appointment_id );
|
||||
$this->assertEquals( $clinic_id, $bill->clinic_id );
|
||||
$this->assertEquals( 'unpaid', $bill->payment_status );
|
||||
|
||||
// STEP 4: Verify bill amount calculation
|
||||
$expected_total = 75.00 + 15.00; // Consultation + BP Check
|
||||
$this->assertEquals( number_format( $expected_total, 2 ), $bill->total_amount );
|
||||
$this->assertEquals( '0.00', $bill->discount );
|
||||
$this->assertEquals( number_format( $expected_total, 2 ), $bill->actual_amount );
|
||||
|
||||
// STEP 5: Doctor adds additional service during encounter
|
||||
$additional_service_response = $this->make_request(
|
||||
"/wp-json/kivicare/v1/encounters/{$encounter_id}/services",
|
||||
'POST',
|
||||
array( 'service_id' => $service_ids[2] ), // Prescription Review
|
||||
$this->doctor_user
|
||||
);
|
||||
$this->assertRestResponse( $additional_service_response, 201 );
|
||||
|
||||
// STEP 6: Verify bill was automatically updated
|
||||
$updated_bill = $wpdb->get_row(
|
||||
$wpdb->prepare( "SELECT * FROM {$wpdb->prefix}kc_bills WHERE id = %d", $bill->id )
|
||||
);
|
||||
|
||||
$new_expected_total = $expected_total + 25.00; // Added Prescription Review
|
||||
$this->assertEquals( number_format( $new_expected_total, 2 ), $updated_bill->total_amount );
|
||||
$this->assertEquals( number_format( $new_expected_total, 2 ), $updated_bill->actual_amount );
|
||||
|
||||
// STEP 7: Test bill retrieval via API
|
||||
$bill_response = $this->make_request( "/wp-json/kivicare/v1/bills/{$bill->id}", 'GET', array(), $this->receptionist_user );
|
||||
$this->assertRestResponse( $bill_response, 200 );
|
||||
|
||||
$bill_data = $bill_response->get_data();
|
||||
$this->assertEquals( $bill->id, $bill_data['id'] );
|
||||
$this->assertEquals( $encounter_id, $bill_data['encounter_id'] );
|
||||
$this->assertEquals( number_format( $new_expected_total, 2 ), $bill_data['total_amount'] );
|
||||
|
||||
// Verify bill includes service breakdown
|
||||
$this->assertArrayHasKey( 'services', $bill_data );
|
||||
$this->assertCount( 3, $bill_data['services'] );
|
||||
|
||||
// STEP 8: Test payment processing
|
||||
$payment_data = array(
|
||||
'amount' => $bill_data['actual_amount'],
|
||||
'payment_method' => 'cash',
|
||||
'notes' => 'Payment received in full',
|
||||
);
|
||||
|
||||
$payment_response = $this->make_request( "/wp-json/kivicare/v1/bills/{$bill->id}/payment", 'POST', $payment_data, $this->receptionist_user );
|
||||
$this->assertRestResponse( $payment_response, 200 );
|
||||
|
||||
// Verify payment status updated
|
||||
$paid_bill = $wpdb->get_row(
|
||||
$wpdb->prepare( "SELECT * FROM {$wpdb->prefix}kc_bills WHERE id = %d", $bill->id )
|
||||
);
|
||||
$this->assertEquals( 'paid', $paid_bill->payment_status );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test billing with discounts and insurance.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_billing_with_discounts_and_insurance() {
|
||||
// This test will fail initially as discount/insurance features aren't implemented
|
||||
$this->markTestIncomplete( 'Billing discounts and insurance not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Setup scenario with discounts
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
// Create encounter
|
||||
$encounter_response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', array(
|
||||
'appointment_id' => $appointment_id,
|
||||
'description' => 'Test encounter for billing with discounts',
|
||||
), $this->doctor_user );
|
||||
|
||||
$encounter_id = $encounter_response->get_data()['id'];
|
||||
|
||||
// STEP 1: Apply senior citizen discount (20%)
|
||||
$discount_data = array(
|
||||
'type' => 'percentage',
|
||||
'value' => 20,
|
||||
'reason' => 'Senior citizen discount',
|
||||
'applied_by' => $this->doctor_user,
|
||||
);
|
||||
|
||||
$discount_response = $this->make_request( "/wp-json/kivicare/v1/bills/encounter/{$encounter_id}/discount", 'POST', $discount_data, $this->doctor_user );
|
||||
$this->assertRestResponse( $discount_response, 200 );
|
||||
|
||||
// STEP 2: Verify discount was applied to bill
|
||||
global $wpdb;
|
||||
$bill = $wpdb->get_row(
|
||||
$wpdb->prepare( "SELECT * FROM {$wpdb->prefix}kc_bills WHERE encounter_id = %d", $encounter_id )
|
||||
);
|
||||
|
||||
$total_amount = floatval( $bill->total_amount );
|
||||
$discount_amount = floatval( $bill->discount );
|
||||
$actual_amount = floatval( $bill->actual_amount );
|
||||
|
||||
$this->assertEquals( $total_amount * 0.20, $discount_amount );
|
||||
$this->assertEquals( $total_amount - $discount_amount, $actual_amount );
|
||||
|
||||
// STEP 3: Test insurance claim processing
|
||||
$insurance_data = array(
|
||||
'insurance_provider' => 'Medicare',
|
||||
'policy_number' => 'POL123456789',
|
||||
'coverage_percentage' => 80,
|
||||
'claim_amount' => $actual_amount,
|
||||
);
|
||||
|
||||
$insurance_response = $this->make_request( "/wp-json/kivicare/v1/bills/{$bill->id}/insurance", 'POST', $insurance_data, $this->receptionist_user );
|
||||
$this->assertRestResponse( $insurance_response, 201 );
|
||||
|
||||
// Verify insurance claim was created
|
||||
$claim = $wpdb->get_row(
|
||||
$wpdb->prepare( "SELECT * FROM {$wpdb->prefix}kc_insurance_claims WHERE bill_id = %d", $bill->id )
|
||||
);
|
||||
$this->assertNotNull( $claim );
|
||||
$this->assertEquals( 'pending', $claim->status );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test billing error handling and edge cases.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_billing_error_handling() {
|
||||
// This test will fail initially as error handling isn't implemented
|
||||
$this->markTestIncomplete( 'Billing error handling not implemented yet - TDD RED phase' );
|
||||
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
|
||||
// Test error scenarios
|
||||
$error_tests = array(
|
||||
// Encounter without appointment
|
||||
array(
|
||||
'scenario' => 'Encounter without appointment',
|
||||
'setup' => function() {
|
||||
return array(
|
||||
'description' => 'Test encounter without appointment',
|
||||
'patient_id' => $this->patient_user,
|
||||
'clinic_id' => $this->create_test_clinic(),
|
||||
);
|
||||
},
|
||||
'expected_error' => 'missing_appointment',
|
||||
),
|
||||
// Appointment without services
|
||||
array(
|
||||
'scenario' => 'Appointment without services',
|
||||
'setup' => function() use ( $clinic_id ) {
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
// Clear any default services
|
||||
global $wpdb;
|
||||
$wpdb->delete( $wpdb->prefix . 'kc_appointment_service_mapping', array( 'appointment_id' => $appointment_id ) );
|
||||
|
||||
return array(
|
||||
'appointment_id' => $appointment_id,
|
||||
'description' => 'Test encounter without services',
|
||||
);
|
||||
},
|
||||
'expected_error' => 'no_billable_services',
|
||||
),
|
||||
);
|
||||
|
||||
foreach ( $error_tests as $test ) {
|
||||
$encounter_data = $test['setup']();
|
||||
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', $encounter_data, $this->doctor_user );
|
||||
|
||||
// Should either prevent encounter creation or generate appropriate billing warning
|
||||
if ( $response->get_status() === 201 ) {
|
||||
// If encounter was created, check for billing warnings
|
||||
$encounter = $response->get_data();
|
||||
$this->assertArrayHasKey( 'billing_warnings', $encounter );
|
||||
} else {
|
||||
// If encounter was prevented, check error code
|
||||
$error_data = $response->get_data();
|
||||
$this->assertEquals( $test['expected_error'], $error_data['code'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test billing permissions and access control.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_billing_permissions() {
|
||||
// This test will fail initially as billing permissions aren't implemented
|
||||
$this->markTestIncomplete( 'Billing permissions not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Create bill
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
$encounter_response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', array(
|
||||
'appointment_id' => $appointment_id,
|
||||
'description' => 'Test encounter for billing permissions',
|
||||
), $this->doctor_user );
|
||||
|
||||
$encounter_id = $encounter_response->get_data()['id'];
|
||||
|
||||
global $wpdb;
|
||||
$bill = $wpdb->get_row(
|
||||
$wpdb->prepare( "SELECT * FROM {$wpdb->prefix}kc_bills WHERE encounter_id = %d", $encounter_id )
|
||||
);
|
||||
|
||||
// Test role-based permissions
|
||||
$permission_tests = array(
|
||||
// View bill permissions
|
||||
array( 'action' => 'GET', 'endpoint' => "/wp-json/kivicare/v1/bills/{$bill->id}", 'user' => $this->admin_user, 'expected' => 200 ),
|
||||
array( 'action' => 'GET', 'endpoint' => "/wp-json/kivicare/v1/bills/{$bill->id}", 'user' => $this->doctor_user, 'expected' => 200 ),
|
||||
array( 'action' => 'GET', 'endpoint' => "/wp-json/kivicare/v1/bills/{$bill->id}", 'user' => $this->receptionist_user, 'expected' => 200 ),
|
||||
array( 'action' => 'GET', 'endpoint' => "/wp-json/kivicare/v1/bills/{$bill->id}", 'user' => $this->patient_user, 'expected' => 200 ), // Own bill
|
||||
|
||||
// Payment processing permissions
|
||||
array( 'action' => 'POST', 'endpoint' => "/wp-json/kivicare/v1/bills/{$bill->id}/payment", 'user' => $this->receptionist_user, 'expected' => 200 ),
|
||||
array( 'action' => 'POST', 'endpoint' => "/wp-json/kivicare/v1/bills/{$bill->id}/payment", 'user' => $this->admin_user, 'expected' => 200 ),
|
||||
array( 'action' => 'POST', 'endpoint' => "/wp-json/kivicare/v1/bills/{$bill->id}/payment", 'user' => $this->doctor_user, 'expected' => 403 ), // Doctor cannot process payments
|
||||
array( 'action' => 'POST', 'endpoint' => "/wp-json/kivicare/v1/bills/{$bill->id}/payment", 'user' => $this->patient_user, 'expected' => 403 ), // Patient cannot process payments
|
||||
);
|
||||
|
||||
foreach ( $permission_tests as $test ) {
|
||||
$data = ( $test['action'] === 'POST' ) ? array( 'amount' => $bill->actual_amount, 'payment_method' => 'cash' ) : array();
|
||||
|
||||
$response = $this->make_request( $test['endpoint'], $test['action'], $data, $test['user'] );
|
||||
$this->assertRestResponse( $response, $test['expected'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test billing reports and analytics.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_billing_reports_and_analytics() {
|
||||
// This test will fail initially as billing reports aren't implemented
|
||||
$this->markTestIncomplete( 'Billing reports not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Create multiple bills with different statuses
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
|
||||
$bill_scenarios = array(
|
||||
array( 'amount' => 100.00, 'status' => 'paid', 'date' => '2024-01-15' ),
|
||||
array( 'amount' => 150.00, 'status' => 'unpaid', 'date' => '2024-01-16' ),
|
||||
array( 'amount' => 75.00, 'status' => 'paid', 'date' => '2024-01-17' ),
|
||||
array( 'amount' => 200.00, 'status' => 'partially_paid', 'date' => '2024-01-18' ),
|
||||
);
|
||||
|
||||
foreach ( $bill_scenarios as $scenario ) {
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
$encounter_response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', array(
|
||||
'appointment_id' => $appointment_id,
|
||||
'description' => 'Test encounter for billing reports',
|
||||
'encounter_date' => $scenario['date'],
|
||||
), $this->doctor_user );
|
||||
|
||||
// Simulate different payment statuses
|
||||
global $wpdb;
|
||||
$encounter_id = $encounter_response->get_data()['id'];
|
||||
$wpdb->update(
|
||||
$wpdb->prefix . 'kc_bills',
|
||||
array(
|
||||
'total_amount' => number_format( $scenario['amount'], 2 ),
|
||||
'actual_amount' => number_format( $scenario['amount'], 2 ),
|
||||
'payment_status' => $scenario['status'],
|
||||
),
|
||||
array( 'encounter_id' => $encounter_id )
|
||||
);
|
||||
}
|
||||
|
||||
// ACT: Generate billing reports
|
||||
$reports_response = $this->make_request( '/wp-json/kivicare/v1/reports/billing', 'GET', array(
|
||||
'start_date' => '2024-01-01',
|
||||
'end_date' => '2024-01-31',
|
||||
'clinic_id' => $clinic_id,
|
||||
), $this->admin_user );
|
||||
|
||||
// ASSERT: Report contains expected data
|
||||
$this->assertRestResponse( $reports_response, 200 );
|
||||
|
||||
$report = $reports_response->get_data();
|
||||
$this->assertArrayHasKey( 'total_revenue', $report );
|
||||
$this->assertArrayHasKey( 'outstanding_amount', $report );
|
||||
$this->assertArrayHasKey( 'payment_summary', $report );
|
||||
|
||||
// Verify calculations
|
||||
$this->assertEquals( 525.00, $report['total_billed'] ); // Sum of all bills
|
||||
$this->assertEquals( 175.00, $report['total_revenue'] ); // Only paid bills
|
||||
$this->assertEquals( 350.00, $report['outstanding_amount'] ); // Unpaid + partially paid
|
||||
}
|
||||
}
|
||||
331
tests/integration/test-clinic-data-access.php
Normal file
331
tests/integration/test-clinic-data-access.php
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Integration tests for Multi-Doctor Clinic Data Access (User Story 3).
|
||||
*
|
||||
* These tests validate complete user stories and MUST FAIL initially (TDD RED phase).
|
||||
*
|
||||
* @package KiviCare_API\Tests\Integration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clinic data access integration tests.
|
||||
*
|
||||
* User Story: Multi-doctor clinic data access with proper isolation
|
||||
*/
|
||||
class Test_Clinic_Data_Access extends KiviCare_API_Test_Case {
|
||||
|
||||
/**
|
||||
* Test multi-doctor clinic data access workflow.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_multi_doctor_clinic_data_access_workflow() {
|
||||
// This test will fail initially as clinic isolation isn't implemented
|
||||
$this->markTestIncomplete( 'Multi-doctor clinic data access not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Setup multi-doctor clinic scenario
|
||||
$clinic1_id = $this->create_test_clinic();
|
||||
$clinic2_id = $this->create_test_clinic();
|
||||
|
||||
// Create additional doctors
|
||||
$doctor2_id = $this->factory->user->create( array(
|
||||
'user_login' => 'doctor2',
|
||||
'user_email' => 'doctor2@clinic.com',
|
||||
'role' => 'doctor',
|
||||
) );
|
||||
|
||||
$doctor3_id = $this->factory->user->create( array(
|
||||
'user_login' => 'doctor3',
|
||||
'user_email' => 'doctor3@clinic.com',
|
||||
'role' => 'doctor',
|
||||
) );
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// Map doctors to clinics
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_doctor_clinic_mappings', array( 'doctor_id' => $this->doctor_user, 'clinic_id' => $clinic1_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_doctor_clinic_mappings', array( 'doctor_id' => $doctor2_id, 'clinic_id' => $clinic1_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_doctor_clinic_mappings', array( 'doctor_id' => $doctor3_id, 'clinic_id' => $clinic2_id ) );
|
||||
|
||||
// Create patients in different clinics
|
||||
$patient1_id = $this->factory->user->create( array( 'role' => 'patient' ) );
|
||||
$patient2_id = $this->factory->user->create( array( 'role' => 'patient' ) );
|
||||
$patient3_id = $this->factory->user->create( array( 'role' => 'patient' ) );
|
||||
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_patient_clinic_mappings', array( 'patient_id' => $patient1_id, 'clinic_id' => $clinic1_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_patient_clinic_mappings', array( 'patient_id' => $patient2_id, 'clinic_id' => $clinic1_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_patient_clinic_mappings', array( 'patient_id' => $patient3_id, 'clinic_id' => $clinic2_id ) );
|
||||
|
||||
// STEP 1: Doctor 1 creates appointment with Patient 1
|
||||
$appointment1_id = $this->create_test_appointment( $clinic1_id, $this->doctor_user, $patient1_id );
|
||||
|
||||
// Doctor 1 creates encounter
|
||||
$encounter1_response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', array(
|
||||
'appointment_id' => $appointment1_id,
|
||||
'description' => 'First encounter by Doctor 1',
|
||||
'diagnosis' => 'Common cold',
|
||||
), $this->doctor_user );
|
||||
|
||||
$this->assertRestResponse( $encounter1_response, 201 );
|
||||
$encounter1_id = $encounter1_response->get_data()['id'];
|
||||
|
||||
// STEP 2: Doctor 2 should be able to access same patient data (same clinic)
|
||||
$patient_access_response = $this->make_request( "/wp-json/kivicare/v1/patients/{$patient1_id}", 'GET', array(), $doctor2_id );
|
||||
$this->assertRestResponse( $patient_access_response, 200 );
|
||||
|
||||
$patient_data = $patient_access_response->get_data();
|
||||
$this->assertEquals( $patient1_id, $patient_data['id'] );
|
||||
$this->assertEquals( $clinic1_id, $patient_data['clinic_id'] );
|
||||
|
||||
// STEP 3: Doctor 2 should see Doctor 1's encounter for same patient
|
||||
$encounters_response = $this->make_request( "/wp-json/kivicare/v1/patients/{$patient1_id}/encounters", 'GET', array(), $doctor2_id );
|
||||
$this->assertRestResponse( $encounters_response, 200 );
|
||||
|
||||
$encounters = $encounters_response->get_data();
|
||||
$this->assertCount( 1, $encounters );
|
||||
$this->assertEquals( $encounter1_id, $encounters[0]['id'] );
|
||||
$this->assertEquals( $this->doctor_user, $encounters[0]['doctor_id'] );
|
||||
|
||||
// STEP 4: Doctor 2 can add notes to the encounter
|
||||
$update_response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter1_id}", 'PUT', array(
|
||||
'description' => 'First encounter by Doctor 1. Additional notes by Doctor 2: Patient responded well to treatment.',
|
||||
), $doctor2_id );
|
||||
|
||||
$this->assertRestResponse( $update_response, 200 );
|
||||
|
||||
// STEP 5: Doctor 3 (different clinic) should NOT access Patient 1
|
||||
$cross_clinic_response = $this->make_request( "/wp-json/kivicare/v1/patients/{$patient1_id}", 'GET', array(), $doctor3_id );
|
||||
$this->assertRestResponse( $cross_clinic_response, 403 );
|
||||
|
||||
$error_data = $cross_clinic_response->get_data();
|
||||
$this->assertEquals( 'clinic_access_denied', $error_data['code'] );
|
||||
|
||||
// STEP 6: Doctor 3 should NOT see encounters from different clinic
|
||||
$cross_encounters_response = $this->make_request( "/wp-json/kivicare/v1/encounters", 'GET', array( 'patient_id' => $patient1_id ), $doctor3_id );
|
||||
$this->assertRestResponse( $cross_encounters_response, 403 );
|
||||
|
||||
// STEP 7: Verify clinic-filtered patient lists
|
||||
$clinic1_patients_response = $this->make_request( '/wp-json/kivicare/v1/patients', 'GET', array(), $this->doctor_user );
|
||||
$this->assertRestResponse( $clinic1_patients_response, 200 );
|
||||
|
||||
$clinic1_patients = $clinic1_patients_response->get_data()['data'];
|
||||
$clinic1_patient_ids = wp_list_pluck( $clinic1_patients, 'id' );
|
||||
|
||||
// Should include patients from clinic 1 only
|
||||
$this->assertContains( $patient1_id, $clinic1_patient_ids );
|
||||
$this->assertContains( $patient2_id, $clinic1_patient_ids );
|
||||
$this->assertNotContains( $patient3_id, $clinic1_patient_ids );
|
||||
|
||||
// STEP 8: Verify appointment scheduling across doctors in same clinic
|
||||
$appointment2_id = $this->create_test_appointment( $clinic1_id, $doctor2_id, $patient2_id );
|
||||
|
||||
// Doctor 1 should see Doctor 2's appointments in clinic view
|
||||
$clinic_appointments_response = $this->make_request( '/wp-json/kivicare/v1/appointments', 'GET', array( 'clinic_id' => $clinic1_id ), $this->doctor_user );
|
||||
$this->assertRestResponse( $clinic_appointments_response, 200 );
|
||||
|
||||
$appointments = $clinic_appointments_response->get_data()['data'];
|
||||
$appointment_ids = wp_list_pluck( $appointments, 'id' );
|
||||
|
||||
$this->assertContains( $appointment1_id, $appointment_ids );
|
||||
$this->assertContains( $appointment2_id, $appointment_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test clinic admin permissions and data access.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_clinic_admin_data_access() {
|
||||
// This test will fail initially as clinic admin roles aren't implemented
|
||||
$this->markTestIncomplete( 'Clinic admin permissions not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Setup clinic with admin
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
|
||||
$clinic_admin_id = $this->factory->user->create( array(
|
||||
'user_login' => 'clinic_admin',
|
||||
'user_email' => 'admin@clinic.com',
|
||||
'role' => 'administrator',
|
||||
) );
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// Update clinic to have admin
|
||||
$wpdb->update(
|
||||
$wpdb->prefix . 'kc_clinics',
|
||||
array( 'clinic_admin_id' => $clinic_admin_id ),
|
||||
array( 'id' => $clinic_id )
|
||||
);
|
||||
|
||||
// Map doctor and patients to clinic
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_doctor_clinic_mappings', array( 'doctor_id' => $this->doctor_user, 'clinic_id' => $clinic_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_patient_clinic_mappings', array( 'patient_id' => $this->patient_user, 'clinic_id' => $clinic_id ) );
|
||||
|
||||
// Create appointment and encounter
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$encounter_response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', array(
|
||||
'appointment_id' => $appointment_id,
|
||||
'description' => 'Test encounter for admin access',
|
||||
), $this->doctor_user );
|
||||
|
||||
$encounter_id = $encounter_response->get_data()['id'];
|
||||
|
||||
// ACT & ASSERT: Clinic admin should have full access to clinic data
|
||||
|
||||
// Access patient data
|
||||
$patient_response = $this->make_request( "/wp-json/kivicare/v1/patients/{$this->patient_user}", 'GET', array(), $clinic_admin_id );
|
||||
$this->assertRestResponse( $patient_response, 200 );
|
||||
|
||||
// Access encounter data
|
||||
$encounter_response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}", 'GET', array(), $clinic_admin_id );
|
||||
$this->assertRestResponse( $encounter_response, 200 );
|
||||
|
||||
// View clinic statistics
|
||||
$stats_response = $this->make_request( "/wp-json/kivicare/v1/clinics/{$clinic_id}/statistics", 'GET', array(), $clinic_admin_id );
|
||||
$this->assertRestResponse( $stats_response, 200 );
|
||||
|
||||
$stats = $stats_response->get_data();
|
||||
$this->assertArrayHasKey( 'total_patients', $stats );
|
||||
$this->assertArrayHasKey( 'total_appointments', $stats );
|
||||
$this->assertArrayHasKey( 'total_encounters', $stats );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test data access auditing and logging.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_clinic_data_access_auditing() {
|
||||
// This test will fail initially as auditing isn't implemented
|
||||
$this->markTestIncomplete( 'Data access auditing not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Setup scenario with different doctors
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$doctor2_id = $this->factory->user->create( array( 'role' => 'doctor' ) );
|
||||
|
||||
global $wpdb;
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_doctor_clinic_mappings', array( 'doctor_id' => $this->doctor_user, 'clinic_id' => $clinic_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_doctor_clinic_mappings', array( 'doctor_id' => $doctor2_id, 'clinic_id' => $clinic_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_patient_clinic_mappings', array( 'patient_id' => $this->patient_user, 'clinic_id' => $clinic_id ) );
|
||||
|
||||
// Track audit log entries
|
||||
$audit_entries = array();
|
||||
add_action( 'kivicare_api_audit_log', function( $action, $resource_type, $resource_id, $user_id ) use ( &$audit_entries ) {
|
||||
$audit_entries[] = compact( 'action', 'resource_type', 'resource_id', 'user_id' );
|
||||
}, 10, 4 );
|
||||
|
||||
// ACT: Multiple data access operations
|
||||
$this->make_request( "/wp-json/kivicare/v1/patients/{$this->patient_user}", 'GET', array(), $this->doctor_user );
|
||||
$this->make_request( "/wp-json/kivicare/v1/patients/{$this->patient_user}", 'GET', array(), $doctor2_id );
|
||||
$this->make_request( "/wp-json/kivicare/v1/patients/{$this->patient_user}", 'PUT', array( 'phone' => '+351999888777' ), $this->doctor_user );
|
||||
|
||||
// ASSERT: Audit entries were created
|
||||
$this->assertCount( 3, $audit_entries );
|
||||
$this->assertEquals( 'read', $audit_entries[0]['action'] );
|
||||
$this->assertEquals( 'patient', $audit_entries[0]['resource_type'] );
|
||||
$this->assertEquals( $this->patient_user, $audit_entries[0]['resource_id'] );
|
||||
$this->assertEquals( $this->doctor_user, $audit_entries[0]['user_id'] );
|
||||
|
||||
$this->assertEquals( 'update', $audit_entries[2]['action'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test clinic data isolation and security.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_clinic_data_isolation_security() {
|
||||
// This test will fail initially as security isolation isn't implemented
|
||||
$this->markTestIncomplete( 'Clinic data isolation security not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Two separate clinics with sensitive data
|
||||
$clinic1_id = $this->create_test_clinic();
|
||||
$clinic2_id = $this->create_test_clinic();
|
||||
|
||||
$doctor_clinic1 = $this->factory->user->create( array( 'role' => 'doctor' ) );
|
||||
$doctor_clinic2 = $this->factory->user->create( array( 'role' => 'doctor' ) );
|
||||
|
||||
$patient_clinic1 = $this->factory->user->create( array( 'role' => 'patient' ) );
|
||||
$patient_clinic2 = $this->factory->user->create( array( 'role' => 'patient' ) );
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// Map to respective clinics
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_doctor_clinic_mappings', array( 'doctor_id' => $doctor_clinic1, 'clinic_id' => $clinic1_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_doctor_clinic_mappings', array( 'doctor_id' => $doctor_clinic2, 'clinic_id' => $clinic2_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_patient_clinic_mappings', array( 'patient_id' => $patient_clinic1, 'clinic_id' => $clinic1_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_patient_clinic_mappings', array( 'patient_id' => $patient_clinic2, 'clinic_id' => $clinic2_id ) );
|
||||
|
||||
// Create sensitive encounters
|
||||
$appointment1_id = $this->create_test_appointment( $clinic1_id, $doctor_clinic1, $patient_clinic1 );
|
||||
$appointment2_id = $this->create_test_appointment( $clinic2_id, $doctor_clinic2, $patient_clinic2 );
|
||||
|
||||
$sensitive_encounter1 = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', array(
|
||||
'appointment_id' => $appointment1_id,
|
||||
'description' => 'CONFIDENTIAL: Mental health consultation - Depression treatment',
|
||||
'diagnosis' => 'Major Depressive Disorder (F32.9)',
|
||||
), $doctor_clinic1 );
|
||||
|
||||
$sensitive_encounter2 = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', array(
|
||||
'appointment_id' => $appointment2_id,
|
||||
'description' => 'CONFIDENTIAL: Substance abuse treatment consultation',
|
||||
'diagnosis' => 'Alcohol Use Disorder (F10.20)',
|
||||
), $doctor_clinic2 );
|
||||
|
||||
$encounter1_id = $sensitive_encounter1->get_data()['id'];
|
||||
$encounter2_id = $sensitive_encounter2->get_data()['id'];
|
||||
|
||||
// Security test scenarios
|
||||
$security_tests = array(
|
||||
// Cross-clinic patient access
|
||||
array(
|
||||
'test' => 'Cross-clinic patient access',
|
||||
'request' => "/wp-json/kivicare/v1/patients/{$patient_clinic2}",
|
||||
'method' => 'GET',
|
||||
'user_id' => $doctor_clinic1,
|
||||
'expected' => 403,
|
||||
),
|
||||
// Cross-clinic encounter access
|
||||
array(
|
||||
'test' => 'Cross-clinic encounter access',
|
||||
'request' => "/wp-json/kivicare/v1/encounters/{$encounter2_id}",
|
||||
'method' => 'GET',
|
||||
'user_id' => $doctor_clinic1,
|
||||
'expected' => 403,
|
||||
),
|
||||
// Direct database manipulation attempts via API
|
||||
array(
|
||||
'test' => 'SQL injection attempt',
|
||||
'request' => '/wp-json/kivicare/v1/patients',
|
||||
'method' => 'GET',
|
||||
'data' => array( 'clinic_id' => "1 OR 1=1; DROP TABLE {$wpdb->prefix}kc_clinics; --" ),
|
||||
'user_id' => $doctor_clinic1,
|
||||
'expected' => 400,
|
||||
),
|
||||
);
|
||||
|
||||
foreach ( $security_tests as $test ) {
|
||||
$response = $this->make_request(
|
||||
$test['request'],
|
||||
$test['method'],
|
||||
isset( $test['data'] ) ? $test['data'] : array(),
|
||||
$test['user_id']
|
||||
);
|
||||
|
||||
$this->assertRestResponse( $response, $test['expected'], "Failed security test: {$test['test']}" );
|
||||
}
|
||||
|
||||
// Verify no data leakage in responses
|
||||
$clinic1_patients_response = $this->make_request( '/wp-json/kivicare/v1/patients', 'GET', array(), $doctor_clinic1 );
|
||||
$patients = $clinic1_patients_response->get_data()['data'];
|
||||
|
||||
foreach ( $patients as $patient ) {
|
||||
$this->assertEquals( $clinic1_id, $patient['clinic_id'], 'Patient from wrong clinic returned in response' );
|
||||
}
|
||||
}
|
||||
}
|
||||
355
tests/integration/test-encounter-workflow.php
Normal file
355
tests/integration/test-encounter-workflow.php
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Integration tests for Encounter Creation Workflow (User Story 2).
|
||||
*
|
||||
* These tests validate complete user stories and MUST FAIL initially (TDD RED phase).
|
||||
*
|
||||
* @package KiviCare_API\Tests\Integration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encounter workflow integration tests.
|
||||
*
|
||||
* User Story: Doctor creates encounter with prescriptions
|
||||
*/
|
||||
class Test_Encounter_Workflow extends KiviCare_API_Test_Case {
|
||||
|
||||
/**
|
||||
* Test complete encounter creation with prescriptions workflow.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_doctor_creates_encounter_with_prescriptions_workflow() {
|
||||
// This test will fail initially as the workflow isn't implemented
|
||||
$this->markTestIncomplete( 'Encounter workflow not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Set up complete scenario
|
||||
wp_set_current_user( $this->doctor_user );
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
|
||||
// Create patient
|
||||
global $wpdb;
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_patient_clinic_mappings', array(
|
||||
'patient_id' => $this->patient_user,
|
||||
'clinic_id' => $clinic_id,
|
||||
));
|
||||
|
||||
// Create appointment
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
// STEP 1: Doctor creates medical encounter
|
||||
$encounter_data = array(
|
||||
'encounter_date' => gmdate( 'Y-m-d' ),
|
||||
'appointment_id' => $appointment_id,
|
||||
'patient_id' => $this->patient_user,
|
||||
'clinic_id' => $clinic_id,
|
||||
'description' => 'Patient presents with mild fever (38.2°C), headache, and fatigue. Symptoms started 2 days ago. No cough or breathing difficulties. Physical examination reveals slightly elevated temperature, normal lung sounds, mild throat redness.',
|
||||
'chief_complaint' => 'Fever and headache for 2 days',
|
||||
'diagnosis' => 'Viral syndrome (R50.9)',
|
||||
'treatment_plan' => 'Rest, hydration, symptomatic treatment with paracetamol',
|
||||
'vital_signs' => json_encode(array(
|
||||
'temperature' => '38.2°C',
|
||||
'blood_pressure' => '120/80',
|
||||
'heart_rate' => '85 bpm',
|
||||
'weight' => '70 kg',
|
||||
)),
|
||||
'status' => 1,
|
||||
);
|
||||
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', $encounter_data, $this->doctor_user );
|
||||
|
||||
// ASSERT: Encounter created successfully
|
||||
$this->assertRestResponse( $response, 201 );
|
||||
$encounter = $response->get_data();
|
||||
$this->assertEquals( $encounter_data['appointment_id'], $encounter['appointment_id'] );
|
||||
$this->assertEquals( $encounter_data['patient_id'], $encounter['patient_id'] );
|
||||
$encounter_id = $encounter['id'];
|
||||
|
||||
// STEP 2: Verify encounter exists in database
|
||||
$db_encounter = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_patient_encounters WHERE id = %d",
|
||||
$encounter_id
|
||||
)
|
||||
);
|
||||
$this->assertNotNull( $db_encounter );
|
||||
$this->assertEquals( $encounter_data['description'], $db_encounter->description );
|
||||
|
||||
// STEP 3: Doctor adds prescriptions to encounter
|
||||
$prescriptions = array(
|
||||
array(
|
||||
'name' => 'Paracetamol 500mg',
|
||||
'frequency' => 'Every 8 hours as needed',
|
||||
'duration' => '5 days',
|
||||
'instruction' => 'Take with water after meals. Do not exceed 4g per day.',
|
||||
'dosage' => '1-2 tablets',
|
||||
'quantity' => '15 tablets',
|
||||
),
|
||||
array(
|
||||
'name' => 'Vitamin C 1000mg',
|
||||
'frequency' => 'Once daily',
|
||||
'duration' => '7 days',
|
||||
'instruction' => 'Take with breakfast to boost immune system.',
|
||||
'dosage' => '1 tablet',
|
||||
'quantity' => '7 tablets',
|
||||
),
|
||||
);
|
||||
|
||||
$prescription_ids = array();
|
||||
foreach ( $prescriptions as $prescription_data ) {
|
||||
$response = $this->make_request(
|
||||
"/wp-json/kivicare/v1/encounters/{$encounter_id}/prescriptions",
|
||||
'POST',
|
||||
$prescription_data,
|
||||
$this->doctor_user
|
||||
);
|
||||
|
||||
$this->assertRestResponse( $response, 201 );
|
||||
$prescription = $response->get_data();
|
||||
$this->assertEquals( $encounter_id, $prescription['encounter_id'] );
|
||||
$this->assertEquals( $this->patient_user, $prescription['patient_id'] );
|
||||
$prescription_ids[] = $prescription['id'];
|
||||
}
|
||||
|
||||
// STEP 4: Verify prescriptions are linked to encounter
|
||||
$encounter_prescriptions_response = $this->make_request(
|
||||
"/wp-json/kivicare/v1/encounters/{$encounter_id}/prescriptions",
|
||||
'GET',
|
||||
array(),
|
||||
$this->doctor_user
|
||||
);
|
||||
|
||||
$this->assertRestResponse( $encounter_prescriptions_response, 200 );
|
||||
$encounter_prescriptions = $encounter_prescriptions_response->get_data();
|
||||
$this->assertCount( 2, $encounter_prescriptions );
|
||||
|
||||
// Verify prescription details
|
||||
foreach ( $encounter_prescriptions as $i => $prescription ) {
|
||||
$this->assertEquals( $prescriptions[$i]['name'], $prescription['name'] );
|
||||
$this->assertEquals( $prescriptions[$i]['frequency'], $prescription['frequency'] );
|
||||
}
|
||||
|
||||
// STEP 5: Verify appointment status was updated to completed
|
||||
$appointment_response = $this->make_request( "/wp-json/kivicare/v1/appointments/{$appointment_id}", 'GET', array(), $this->doctor_user );
|
||||
$this->assertRestResponse( $appointment_response, 200 );
|
||||
|
||||
$appointment = $appointment_response->get_data();
|
||||
$this->assertEquals( 'completed', $appointment['status'] );
|
||||
|
||||
// STEP 6: Verify automatic bill generation
|
||||
$bill = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_bills WHERE encounter_id = %d",
|
||||
$encounter_id
|
||||
)
|
||||
);
|
||||
$this->assertNotNull( $bill, 'Bill should be automatically generated for encounter' );
|
||||
$this->assertEquals( $encounter_id, $bill->encounter_id );
|
||||
$this->assertEquals( $appointment_id, $bill->appointment_id );
|
||||
$this->assertEquals( 'unpaid', $bill->payment_status );
|
||||
|
||||
// STEP 7: Verify patient can view encounter and prescriptions
|
||||
$patient_encounter_response = $this->make_request( "/wp-json/kivicare/v1/encounters/{$encounter_id}", 'GET', array(), $this->patient_user );
|
||||
$this->assertRestResponse( $patient_encounter_response, 200 );
|
||||
|
||||
$patient_encounter = $patient_encounter_response->get_data();
|
||||
$this->assertEquals( $encounter_id, $patient_encounter['id'] );
|
||||
// Sensitive medical details should be filtered for patient view
|
||||
$this->assertArrayNotHasKey( 'vital_signs', $patient_encounter );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test encounter creation triggers proper workflow events.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_encounter_creation_workflow_events() {
|
||||
// This test will fail initially as workflow events aren't implemented
|
||||
$this->markTestIncomplete( 'Encounter workflow events not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Setup scenario
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
// Track WordPress actions/hooks that should fire
|
||||
$actions_fired = array();
|
||||
$test_instance = $this;
|
||||
|
||||
add_action( 'kivicare_encounter_created', function( $encounter_id, $encounter_data ) use ( &$actions_fired, $test_instance ) {
|
||||
$actions_fired['encounter_created'] = array( $encounter_id, $encounter_data );
|
||||
}, 10, 2 );
|
||||
|
||||
add_action( 'kivicare_appointment_completed', function( $appointment_id ) use ( &$actions_fired, $test_instance ) {
|
||||
$actions_fired['appointment_completed'] = $appointment_id;
|
||||
} );
|
||||
|
||||
add_action( 'kivicare_bill_generated', function( $bill_id, $encounter_id ) use ( &$actions_fired, $test_instance ) {
|
||||
$actions_fired['bill_generated'] = array( $bill_id, $encounter_id );
|
||||
}, 10, 2 );
|
||||
|
||||
// ACT: Create encounter
|
||||
$encounter_data = array(
|
||||
'appointment_id' => $appointment_id,
|
||||
'description' => 'Test encounter for workflow events',
|
||||
'status' => 1,
|
||||
);
|
||||
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', $encounter_data, $this->doctor_user );
|
||||
$this->assertRestResponse( $response, 201 );
|
||||
|
||||
// ASSERT: All workflow events were triggered
|
||||
$this->assertArrayHasKey( 'encounter_created', $actions_fired );
|
||||
$this->assertArrayHasKey( 'appointment_completed', $actions_fired );
|
||||
$this->assertArrayHasKey( 'bill_generated', $actions_fired );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test encounter data integrity and validation.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_encounter_data_integrity() {
|
||||
// This test will fail initially as validation isn't implemented
|
||||
$this->markTestIncomplete( 'Encounter data validation not implemented yet - TDD RED phase' );
|
||||
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
// Test various validation scenarios
|
||||
$validation_tests = array(
|
||||
// Missing required fields
|
||||
array(
|
||||
'data' => array( 'description' => 'Test encounter' ),
|
||||
'status' => 400,
|
||||
'code' => 'rest_missing_callback_param',
|
||||
),
|
||||
// Invalid appointment ID
|
||||
array(
|
||||
'data' => array( 'appointment_id' => 99999, 'description' => 'Test' ),
|
||||
'status' => 400,
|
||||
'code' => 'invalid_appointment',
|
||||
),
|
||||
// Encounter for completed appointment
|
||||
array(
|
||||
'setup' => function() use ( $appointment_id ) {
|
||||
global $wpdb;
|
||||
$wpdb->update(
|
||||
$wpdb->prefix . 'kc_appointments',
|
||||
array( 'status' => 0 ), // Mark as completed
|
||||
array( 'id' => $appointment_id )
|
||||
);
|
||||
},
|
||||
'data' => array( 'appointment_id' => $appointment_id, 'description' => 'Test' ),
|
||||
'status' => 409,
|
||||
'code' => 'appointment_already_completed',
|
||||
),
|
||||
);
|
||||
|
||||
foreach ( $validation_tests as $test ) {
|
||||
if ( isset( $test['setup'] ) ) {
|
||||
$test['setup']();
|
||||
}
|
||||
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', $test['data'], $this->doctor_user );
|
||||
$this->assertRestResponse( $response, $test['status'] );
|
||||
|
||||
if ( isset( $test['code'] ) ) {
|
||||
$error_data = $response->get_data();
|
||||
$this->assertEquals( $test['code'], $error_data['code'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test prescription validation and drug interaction checks.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_prescription_validation_workflow() {
|
||||
// This test will fail initially as prescription validation isn't implemented
|
||||
$this->markTestIncomplete( 'Prescription validation not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Create encounter
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
$encounter_response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', array(
|
||||
'appointment_id' => $appointment_id,
|
||||
'description' => 'Test encounter for prescription validation',
|
||||
), $this->doctor_user );
|
||||
|
||||
$encounter_id = $encounter_response->get_data()['id'];
|
||||
|
||||
// Test prescription validation
|
||||
$prescription_tests = array(
|
||||
// Valid prescription
|
||||
array(
|
||||
'data' => array( 'name' => 'Paracetamol 500mg', 'frequency' => 'Every 8h', 'duration' => '7 days' ),
|
||||
'status' => 201,
|
||||
),
|
||||
// Missing medication name
|
||||
array(
|
||||
'data' => array( 'frequency' => 'Every 8h', 'duration' => '7 days' ),
|
||||
'status' => 400,
|
||||
),
|
||||
// Invalid duration format
|
||||
array(
|
||||
'data' => array( 'name' => 'Test Med', 'frequency' => 'Daily', 'duration' => 'forever' ),
|
||||
'status' => 400,
|
||||
),
|
||||
);
|
||||
|
||||
foreach ( $prescription_tests as $test ) {
|
||||
$response = $this->make_request(
|
||||
"/wp-json/kivicare/v1/encounters/{$encounter_id}/prescriptions",
|
||||
'POST',
|
||||
$test['data'],
|
||||
$this->doctor_user
|
||||
);
|
||||
|
||||
$this->assertRestResponse( $response, $test['status'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test encounter permissions and access control.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_encounter_permissions_workflow() {
|
||||
// This test will fail initially as permissions aren't implemented
|
||||
$this->markTestIncomplete( 'Encounter permissions not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Create encounter
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
$encounter_data = array(
|
||||
'appointment_id' => $appointment_id,
|
||||
'description' => 'Test encounter for permissions',
|
||||
);
|
||||
|
||||
// Test role-based permissions
|
||||
$permission_tests = array(
|
||||
array( 'user_id' => $this->doctor_user, 'expected_status' => 201 ), // Doctor can create
|
||||
array( 'user_id' => $this->admin_user, 'expected_status' => 201 ), // Admin can create
|
||||
array( 'user_id' => $this->patient_user, 'expected_status' => 403 ), // Patient cannot create
|
||||
array( 'user_id' => $this->receptionist_user, 'expected_status' => 403 ), // Receptionist cannot create
|
||||
);
|
||||
|
||||
foreach ( $permission_tests as $i => $test ) {
|
||||
// Create unique appointment for each test
|
||||
$test_appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
$test_data = $encounter_data;
|
||||
$test_data['appointment_id'] = $test_appointment_id;
|
||||
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', $test_data, $test['user_id'] );
|
||||
$this->assertRestResponse( $response, $test['expected_status'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
280
tests/integration/test-patient-creation-workflow.php
Normal file
280
tests/integration/test-patient-creation-workflow.php
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Integration tests for Patient Creation Workflow (User Story 1).
|
||||
*
|
||||
* These tests validate complete user stories and MUST FAIL initially (TDD RED phase).
|
||||
*
|
||||
* @package KiviCare_API\Tests\Integration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Patient creation workflow integration tests.
|
||||
*
|
||||
* User Story: Doctor creates patient record
|
||||
*/
|
||||
class Test_Patient_Creation_Workflow extends KiviCare_API_Test_Case {
|
||||
|
||||
/**
|
||||
* Test complete patient creation workflow.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_doctor_creates_patient_record_workflow() {
|
||||
// This test will fail initially as the workflow isn't implemented
|
||||
$this->markTestIncomplete( 'Patient creation workflow not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Doctor authentication and clinic setup
|
||||
wp_set_current_user( $this->doctor_user );
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
|
||||
// Map doctor to clinic
|
||||
global $wpdb;
|
||||
$wpdb->insert(
|
||||
$wpdb->prefix . 'kc_doctor_clinic_mappings',
|
||||
array(
|
||||
'doctor_id' => $this->doctor_user,
|
||||
'clinic_id' => $clinic_id,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
)
|
||||
);
|
||||
|
||||
// STEP 1: Doctor creates new patient via API
|
||||
$patient_data = array(
|
||||
'display_name' => 'João Silva Santos',
|
||||
'user_email' => 'joao.santos@example.com',
|
||||
'first_name' => 'João',
|
||||
'last_name' => 'Santos',
|
||||
'clinic_id' => $clinic_id,
|
||||
'phone' => '+351912345678',
|
||||
'address' => 'Rua das Flores, 123',
|
||||
'city' => 'Lisboa',
|
||||
'postal_code' => '1000-001',
|
||||
'birth_date' => '1985-05-15',
|
||||
'gender' => 'M',
|
||||
);
|
||||
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/patients', 'POST', $patient_data, $this->doctor_user );
|
||||
|
||||
// ASSERT: Patient created successfully
|
||||
$this->assertRestResponse( $response, 201 );
|
||||
$patient = $response->get_data();
|
||||
$this->assertEquals( $patient_data['display_name'], $patient['display_name'] );
|
||||
$this->assertEquals( $patient_data['user_email'], $patient['user_email'] );
|
||||
$patient_id = $patient['id'];
|
||||
|
||||
// STEP 2: Verify patient exists in WordPress users table
|
||||
$wp_user = get_user_by( 'id', $patient_id );
|
||||
$this->assertInstanceOf( 'WP_User', $wp_user );
|
||||
$this->assertEquals( $patient_data['user_email'], $wp_user->user_email );
|
||||
$this->assertEquals( $patient_data['display_name'], $wp_user->display_name );
|
||||
$this->assertTrue( in_array( 'patient', $wp_user->roles, true ) );
|
||||
|
||||
// STEP 3: Verify patient-clinic mapping was created
|
||||
$mapping = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->prefix}kc_patient_clinic_mappings WHERE patient_id = %d AND clinic_id = %d",
|
||||
$patient_id,
|
||||
$clinic_id
|
||||
)
|
||||
);
|
||||
$this->assertNotNull( $mapping );
|
||||
$this->assertEquals( $patient_id, $mapping->patient_id );
|
||||
$this->assertEquals( $clinic_id, $mapping->clinic_id );
|
||||
|
||||
// STEP 4: Verify patient metadata was stored correctly
|
||||
$phone = get_user_meta( $patient_id, 'phone', true );
|
||||
$address = get_user_meta( $patient_id, 'address', true );
|
||||
$birth_date = get_user_meta( $patient_id, 'birth_date', true );
|
||||
|
||||
$this->assertEquals( $patient_data['phone'], $phone );
|
||||
$this->assertEquals( $patient_data['address'], $address );
|
||||
$this->assertEquals( $patient_data['birth_date'], $birth_date );
|
||||
|
||||
// STEP 5: Verify doctor can retrieve patient data
|
||||
$get_response = $this->make_request( "/wp-json/kivicare/v1/patients/{$patient_id}", 'GET', array(), $this->doctor_user );
|
||||
$this->assertRestResponse( $get_response, 200 );
|
||||
|
||||
$retrieved_patient = $get_response->get_data();
|
||||
$this->assertEquals( $patient_id, $retrieved_patient['id'] );
|
||||
$this->assertEquals( $clinic_id, $retrieved_patient['clinic_id'] );
|
||||
|
||||
// STEP 6: Verify patient appears in clinic's patient list
|
||||
$list_response = $this->make_request( '/wp-json/kivicare/v1/patients', 'GET', array( 'clinic_id' => $clinic_id ), $this->doctor_user );
|
||||
$this->assertRestResponse( $list_response, 200 );
|
||||
|
||||
$patients_list = $list_response->get_data();
|
||||
$this->assertIsArray( $patients_list['data'] );
|
||||
|
||||
$found_patient = false;
|
||||
foreach ( $patients_list['data'] as $list_patient ) {
|
||||
if ( $list_patient['id'] === $patient_id ) {
|
||||
$found_patient = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertTrue( $found_patient, 'Created patient should appear in clinic patient list' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test patient creation with duplicate email handling.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_patient_creation_duplicate_email_handling() {
|
||||
// This test will fail initially as duplicate handling isn't implemented
|
||||
$this->markTestIncomplete( 'Duplicate email handling not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: First patient created successfully
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$patient_data = array(
|
||||
'display_name' => 'João Silva',
|
||||
'user_email' => 'joao@example.com',
|
||||
'clinic_id' => $clinic_id,
|
||||
);
|
||||
|
||||
$first_response = $this->make_request( '/wp-json/kivicare/v1/patients', 'POST', $patient_data, $this->doctor_user );
|
||||
$this->assertRestResponse( $first_response, 201 );
|
||||
|
||||
// ACT: Try to create second patient with same email
|
||||
$duplicate_data = array(
|
||||
'display_name' => 'João Santos',
|
||||
'user_email' => 'joao@example.com', // Same email
|
||||
'clinic_id' => $clinic_id,
|
||||
);
|
||||
|
||||
$duplicate_response = $this->make_request( '/wp-json/kivicare/v1/patients', 'POST', $duplicate_data, $this->doctor_user );
|
||||
|
||||
// ASSERT: Should return appropriate error
|
||||
$this->assertRestResponse( $duplicate_response, 409 );
|
||||
|
||||
$error_data = $duplicate_response->get_data();
|
||||
$this->assertEquals( 'duplicate_email', $error_data['code'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test patient creation with data validation.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_patient_creation_data_validation() {
|
||||
// This test will fail initially as validation isn't implemented
|
||||
$this->markTestIncomplete( 'Patient data validation not implemented yet - TDD RED phase' );
|
||||
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
|
||||
// Test required field validation
|
||||
$invalid_data_sets = array(
|
||||
// Missing name
|
||||
array(
|
||||
'data' => array( 'user_email' => 'test@example.com', 'clinic_id' => $clinic_id ),
|
||||
'field' => 'display_name',
|
||||
),
|
||||
// Missing email
|
||||
array(
|
||||
'data' => array( 'display_name' => 'Test Patient', 'clinic_id' => $clinic_id ),
|
||||
'field' => 'user_email',
|
||||
),
|
||||
// Invalid email format
|
||||
array(
|
||||
'data' => array( 'display_name' => 'Test Patient', 'user_email' => 'invalid-email', 'clinic_id' => $clinic_id ),
|
||||
'field' => 'user_email',
|
||||
),
|
||||
// Missing clinic
|
||||
array(
|
||||
'data' => array( 'display_name' => 'Test Patient', 'user_email' => 'test@example.com' ),
|
||||
'field' => 'clinic_id',
|
||||
),
|
||||
// Invalid clinic ID
|
||||
array(
|
||||
'data' => array( 'display_name' => 'Test Patient', 'user_email' => 'test@example.com', 'clinic_id' => 99999 ),
|
||||
'field' => 'clinic_id',
|
||||
),
|
||||
);
|
||||
|
||||
foreach ( $invalid_data_sets as $test_case ) {
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/patients', 'POST', $test_case['data'], $this->doctor_user );
|
||||
|
||||
$this->assertRestResponse( $response, 400 );
|
||||
|
||||
$error_data = $response->get_data();
|
||||
$this->assertArrayHasKey( 'data', $error_data );
|
||||
$this->assertArrayHasKey( 'params', $error_data['data'] );
|
||||
$this->assertArrayHasKey( $test_case['field'], $error_data['data']['params'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test patient creation permissions by role.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_patient_creation_permissions() {
|
||||
// This test will fail initially as permissions aren't implemented
|
||||
$this->markTestIncomplete( 'Patient creation permissions not implemented yet - TDD RED phase' );
|
||||
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$patient_data = array(
|
||||
'display_name' => 'Test Patient',
|
||||
'user_email' => 'test@example.com',
|
||||
'clinic_id' => $clinic_id,
|
||||
);
|
||||
|
||||
// Test different role permissions
|
||||
$role_tests = array(
|
||||
array( 'user_id' => $this->admin_user, 'expected_status' => 201 ), // Admin can create
|
||||
array( 'user_id' => $this->doctor_user, 'expected_status' => 201 ), // Doctor can create
|
||||
array( 'user_id' => $this->receptionist_user, 'expected_status' => 201 ), // Receptionist can create
|
||||
array( 'user_id' => $this->patient_user, 'expected_status' => 403 ), // Patient cannot create
|
||||
);
|
||||
|
||||
foreach ( $role_tests as $i => $test ) {
|
||||
// Make email unique for each test
|
||||
$test_data = $patient_data;
|
||||
$test_data['user_email'] = "test{$i}@example.com";
|
||||
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/patients', 'POST', $test_data, $test['user_id'] );
|
||||
$this->assertRestResponse( $response, $test['expected_status'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test patient creation with clinic isolation.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_patient_creation_clinic_isolation() {
|
||||
// This test will fail initially as clinic isolation isn't implemented
|
||||
$this->markTestIncomplete( 'Clinic isolation not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Two different clinics and doctors
|
||||
$clinic1_id = $this->create_test_clinic();
|
||||
$clinic2_id = $this->create_test_clinic();
|
||||
|
||||
$doctor2_id = $this->factory->user->create( array( 'role' => 'doctor' ) );
|
||||
|
||||
global $wpdb;
|
||||
// Map doctors to their respective clinics
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_doctor_clinic_mappings', array( 'doctor_id' => $this->doctor_user, 'clinic_id' => $clinic1_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_doctor_clinic_mappings', array( 'doctor_id' => $doctor2_id, 'clinic_id' => $clinic2_id ) );
|
||||
|
||||
// ACT: Doctor 1 tries to create patient in Doctor 2's clinic
|
||||
$patient_data = array(
|
||||
'display_name' => 'Cross Clinic Patient',
|
||||
'user_email' => 'cross@example.com',
|
||||
'clinic_id' => $clinic2_id, // Different clinic
|
||||
);
|
||||
|
||||
$response = $this->make_request( '/wp-json/kivicare/v1/patients', 'POST', $patient_data, $this->doctor_user );
|
||||
|
||||
// ASSERT: Should be forbidden
|
||||
$this->assertRestResponse( $response, 403 );
|
||||
|
||||
$error_data = $response->get_data();
|
||||
$this->assertEquals( 'clinic_access_denied', $error_data['code'] );
|
||||
}
|
||||
}
|
||||
409
tests/integration/test-role-permissions.php
Normal file
409
tests/integration/test-role-permissions.php
Normal file
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Integration tests for Role-Based Access Control (User Story 5).
|
||||
*
|
||||
* These tests validate complete user stories and MUST FAIL initially (TDD RED phase).
|
||||
*
|
||||
* @package KiviCare_API\Tests\Integration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Role-based permissions integration tests.
|
||||
*
|
||||
* User Story: Role-based access control across all API endpoints
|
||||
*/
|
||||
class Test_Role_Permissions extends KiviCare_API_Test_Case {
|
||||
|
||||
/**
|
||||
* Test complete role-based access control workflow.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_role_based_access_control_workflow() {
|
||||
// This test will fail initially as role-based permissions aren't implemented
|
||||
$this->markTestIncomplete( 'Role-based access control not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Setup complete scenario with all roles
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// Map users to clinic
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_doctor_clinic_mappings', array( 'doctor_id' => $this->doctor_user, 'clinic_id' => $clinic_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_patient_clinic_mappings', array( 'patient_id' => $this->patient_user, 'clinic_id' => $clinic_id ) );
|
||||
|
||||
// Create test data
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
$encounter_response = $this->make_request( '/wp-json/kivicare/v1/encounters', 'POST', array(
|
||||
'appointment_id' => $appointment_id,
|
||||
'description' => 'Test encounter for permission testing',
|
||||
), $this->doctor_user );
|
||||
$encounter_id = $encounter_response->get_data()['id'];
|
||||
|
||||
// Define permission matrix for all roles and endpoints
|
||||
$permission_matrix = array(
|
||||
// ADMINISTRATOR - Full access to everything
|
||||
'administrator' => array(
|
||||
'user_id' => $this->admin_user,
|
||||
'permissions' => array(
|
||||
// Clinics
|
||||
array( 'GET', '/wp-json/kivicare/v1/clinics', 200 ),
|
||||
array( 'POST', '/wp-json/kivicare/v1/clinics', 201 ),
|
||||
array( 'PUT', "/wp-json/kivicare/v1/clinics/{$clinic_id}", 200 ),
|
||||
array( 'DELETE', "/wp-json/kivicare/v1/clinics/{$clinic_id}", 200 ),
|
||||
|
||||
// Patients
|
||||
array( 'GET', '/wp-json/kivicare/v1/patients', 200 ),
|
||||
array( 'POST', '/wp-json/kivicare/v1/patients', 201 ),
|
||||
array( 'GET', "/wp-json/kivicare/v1/patients/{$this->patient_user}", 200 ),
|
||||
array( 'PUT', "/wp-json/kivicare/v1/patients/{$this->patient_user}", 200 ),
|
||||
|
||||
// Appointments
|
||||
array( 'GET', '/wp-json/kivicare/v1/appointments', 200 ),
|
||||
array( 'POST', '/wp-json/kivicare/v1/appointments', 201 ),
|
||||
array( 'GET', "/wp-json/kivicare/v1/appointments/{$appointment_id}", 200 ),
|
||||
array( 'PUT', "/wp-json/kivicare/v1/appointments/{$appointment_id}", 200 ),
|
||||
array( 'DELETE', "/wp-json/kivicare/v1/appointments/{$appointment_id}", 200 ),
|
||||
|
||||
// Encounters
|
||||
array( 'GET', '/wp-json/kivicare/v1/encounters', 200 ),
|
||||
array( 'POST', '/wp-json/kivicare/v1/encounters', 201 ),
|
||||
array( 'GET', "/wp-json/kivicare/v1/encounters/{$encounter_id}", 200 ),
|
||||
array( 'PUT', "/wp-json/kivicare/v1/encounters/{$encounter_id}", 200 ),
|
||||
|
||||
// Bills
|
||||
array( 'GET', '/wp-json/kivicare/v1/bills', 200 ),
|
||||
array( 'POST', "/wp-json/kivicare/v1/bills/1/payment", 200 ),
|
||||
),
|
||||
),
|
||||
|
||||
// DOCTOR - Medical access, read patients, create encounters
|
||||
'doctor' => array(
|
||||
'user_id' => $this->doctor_user,
|
||||
'permissions' => array(
|
||||
// Clinics - Read only
|
||||
array( 'GET', '/wp-json/kivicare/v1/clinics', 200 ),
|
||||
array( 'POST', '/wp-json/kivicare/v1/clinics', 403 ),
|
||||
array( 'PUT', "/wp-json/kivicare/v1/clinics/{$clinic_id}", 403 ),
|
||||
array( 'DELETE', "/wp-json/kivicare/v1/clinics/{$clinic_id}", 403 ),
|
||||
|
||||
// Patients - Full access to clinic patients
|
||||
array( 'GET', '/wp-json/kivicare/v1/patients', 200 ),
|
||||
array( 'POST', '/wp-json/kivicare/v1/patients', 201 ),
|
||||
array( 'GET', "/wp-json/kivicare/v1/patients/{$this->patient_user}", 200 ),
|
||||
array( 'PUT', "/wp-json/kivicare/v1/patients/{$this->patient_user}", 200 ),
|
||||
|
||||
// Appointments - Read and update own appointments
|
||||
array( 'GET', '/wp-json/kivicare/v1/appointments', 200 ),
|
||||
array( 'POST', '/wp-json/kivicare/v1/appointments', 403 ), // Cannot create
|
||||
array( 'GET', "/wp-json/kivicare/v1/appointments/{$appointment_id}", 200 ),
|
||||
array( 'PUT', "/wp-json/kivicare/v1/appointments/{$appointment_id}", 200 ),
|
||||
array( 'DELETE', "/wp-json/kivicare/v1/appointments/{$appointment_id}", 403 ),
|
||||
|
||||
// Encounters - Full access
|
||||
array( 'GET', '/wp-json/kivicare/v1/encounters', 200 ),
|
||||
array( 'POST', '/wp-json/kivicare/v1/encounters', 201 ),
|
||||
array( 'GET', "/wp-json/kivicare/v1/encounters/{$encounter_id}", 200 ),
|
||||
array( 'PUT', "/wp-json/kivicare/v1/encounters/{$encounter_id}", 200 ),
|
||||
|
||||
// Prescriptions - Full access
|
||||
array( 'POST', "/wp-json/kivicare/v1/encounters/{$encounter_id}/prescriptions", 201 ),
|
||||
|
||||
// Bills - Read only
|
||||
array( 'GET', '/wp-json/kivicare/v1/bills', 200 ),
|
||||
array( 'POST', "/wp-json/kivicare/v1/bills/1/payment", 403 ),
|
||||
),
|
||||
),
|
||||
|
||||
// PATIENT - Own data only, read-only access
|
||||
'patient' => array(
|
||||
'user_id' => $this->patient_user,
|
||||
'permissions' => array(
|
||||
// Clinics - No access
|
||||
array( 'GET', '/wp-json/kivicare/v1/clinics', 403 ),
|
||||
array( 'POST', '/wp-json/kivicare/v1/clinics', 403 ),
|
||||
|
||||
// Patients - Own data only
|
||||
array( 'GET', '/wp-json/kivicare/v1/patients', 403 ), // Cannot list all patients
|
||||
array( 'POST', '/wp-json/kivicare/v1/patients', 403 ),
|
||||
array( 'GET', "/wp-json/kivicare/v1/patients/{$this->patient_user}", 200 ), // Own data
|
||||
array( 'PUT', "/wp-json/kivicare/v1/patients/{$this->patient_user}", 200 ), // Update own data
|
||||
|
||||
// Appointments - Own appointments only
|
||||
array( 'GET', '/wp-json/kivicare/v1/appointments', 200 ), // Filtered to own
|
||||
array( 'POST', '/wp-json/kivicare/v1/appointments', 201 ), // Can book appointments
|
||||
array( 'GET', "/wp-json/kivicare/v1/appointments/{$appointment_id}", 200 ),
|
||||
array( 'PUT', "/wp-json/kivicare/v1/appointments/{$appointment_id}", 403 ), // Cannot modify
|
||||
array( 'DELETE', "/wp-json/kivicare/v1/appointments/{$appointment_id}", 200 ), // Can cancel own
|
||||
|
||||
// Encounters - Own encounters, read-only
|
||||
array( 'GET', '/wp-json/kivicare/v1/encounters', 200 ), // Filtered to own
|
||||
array( 'POST', '/wp-json/kivicare/v1/encounters', 403 ),
|
||||
array( 'GET', "/wp-json/kivicare/v1/encounters/{$encounter_id}", 200 ),
|
||||
array( 'PUT', "/wp-json/kivicare/v1/encounters/{$encounter_id}", 403 ),
|
||||
|
||||
// Prescriptions - Read own prescriptions
|
||||
array( 'GET', "/wp-json/kivicare/v1/patients/{$this->patient_user}/prescriptions", 200 ),
|
||||
array( 'POST', "/wp-json/kivicare/v1/encounters/{$encounter_id}/prescriptions", 403 ),
|
||||
|
||||
// Bills - Own bills only
|
||||
array( 'GET', '/wp-json/kivicare/v1/bills', 200 ), // Filtered to own
|
||||
array( 'POST', "/wp-json/kivicare/v1/bills/1/payment", 403 ),
|
||||
),
|
||||
),
|
||||
|
||||
// RECEPTIONIST - Appointments and basic patient data
|
||||
'receptionist' => array(
|
||||
'user_id' => $this->receptionist_user,
|
||||
'permissions' => array(
|
||||
// Clinics - Read only
|
||||
array( 'GET', '/wp-json/kivicare/v1/clinics', 200 ),
|
||||
array( 'POST', '/wp-json/kivicare/v1/clinics', 403 ),
|
||||
|
||||
// Patients - Basic access
|
||||
array( 'GET', '/wp-json/kivicare/v1/patients', 200 ),
|
||||
array( 'POST', '/wp-json/kivicare/v1/patients', 201 ),
|
||||
array( 'GET', "/wp-json/kivicare/v1/patients/{$this->patient_user}", 200 ),
|
||||
array( 'PUT', "/wp-json/kivicare/v1/patients/{$this->patient_user}", 200 ), // Basic info only
|
||||
|
||||
// Appointments - Full access
|
||||
array( 'GET', '/wp-json/kivicare/v1/appointments', 200 ),
|
||||
array( 'POST', '/wp-json/kivicare/v1/appointments', 201 ),
|
||||
array( 'GET', "/wp-json/kivicare/v1/appointments/{$appointment_id}", 200 ),
|
||||
array( 'PUT', "/wp-json/kivicare/v1/appointments/{$appointment_id}", 200 ),
|
||||
array( 'DELETE', "/wp-json/kivicare/v1/appointments/{$appointment_id}", 200 ),
|
||||
|
||||
// Encounters - No access to medical data
|
||||
array( 'GET', '/wp-json/kivicare/v1/encounters', 403 ),
|
||||
array( 'POST', '/wp-json/kivicare/v1/encounters', 403 ),
|
||||
array( 'GET', "/wp-json/kivicare/v1/encounters/{$encounter_id}", 403 ),
|
||||
|
||||
// Bills - Full access
|
||||
array( 'GET', '/wp-json/kivicare/v1/bills', 200 ),
|
||||
array( 'POST', "/wp-json/kivicare/v1/bills/1/payment", 200 ),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Execute permission tests for each role
|
||||
foreach ( $permission_matrix as $role => $role_data ) {
|
||||
foreach ( $role_data['permissions'] as $permission ) {
|
||||
list( $method, $endpoint, $expected_status ) = $permission;
|
||||
|
||||
// Prepare test data based on method
|
||||
$test_data = array();
|
||||
if ( $method === 'POST' ) {
|
||||
if ( strpos( $endpoint, 'clinics' ) !== false ) {
|
||||
$test_data = array( 'name' => 'Test Clinic', 'email' => 'test@clinic.com' );
|
||||
} elseif ( strpos( $endpoint, 'patients' ) !== false ) {
|
||||
$test_data = array( 'display_name' => 'Test Patient', 'user_email' => 'test@patient.com', 'clinic_id' => $clinic_id );
|
||||
} elseif ( strpos( $endpoint, 'appointments' ) !== false ) {
|
||||
$test_data = array(
|
||||
'appointment_start_date' => gmdate( 'Y-m-d', strtotime( '+2 days' ) ),
|
||||
'appointment_start_time' => '10:00:00',
|
||||
'appointment_end_time' => '10:30:00',
|
||||
'doctor_id' => $this->doctor_user,
|
||||
'patient_id' => $this->patient_user,
|
||||
'clinic_id' => $clinic_id,
|
||||
);
|
||||
} elseif ( strpos( $endpoint, 'encounters' ) !== false ) {
|
||||
$test_data = array( 'appointment_id' => $appointment_id, 'description' => 'Test encounter' );
|
||||
} elseif ( strpos( $endpoint, 'prescriptions' ) !== false ) {
|
||||
$test_data = array( 'name' => 'Test Medicine', 'frequency' => 'Daily', 'duration' => '7 days' );
|
||||
} elseif ( strpos( $endpoint, 'payment' ) !== false ) {
|
||||
$test_data = array( 'amount' => '50.00', 'payment_method' => 'cash' );
|
||||
}
|
||||
} elseif ( $method === 'PUT' ) {
|
||||
if ( strpos( $endpoint, 'clinics' ) !== false ) {
|
||||
$test_data = array( 'name' => 'Updated Clinic Name' );
|
||||
} elseif ( strpos( $endpoint, 'patients' ) !== false ) {
|
||||
$test_data = array( 'phone' => '+351999888777' );
|
||||
} elseif ( strpos( $endpoint, 'appointments' ) !== false ) {
|
||||
$test_data = array( 'description' => 'Updated appointment notes' );
|
||||
} elseif ( strpos( $endpoint, 'encounters' ) !== false ) {
|
||||
$test_data = array( 'description' => 'Updated encounter notes' );
|
||||
}
|
||||
}
|
||||
|
||||
// Make request and assert
|
||||
$response = $this->make_request( $endpoint, $method, $test_data, $role_data['user_id'] );
|
||||
$this->assertRestResponse(
|
||||
$response,
|
||||
$expected_status,
|
||||
"Failed permission test for role {$role}: {$method} {$endpoint} (expected {$expected_status}, got {$response->get_status()})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test data filtering based on user role and clinic access.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_role_based_data_filtering() {
|
||||
// This test will fail initially as data filtering isn't implemented
|
||||
$this->markTestIncomplete( 'Role-based data filtering not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Setup multi-clinic scenario
|
||||
$clinic1_id = $this->create_test_clinic();
|
||||
$clinic2_id = $this->create_test_clinic();
|
||||
|
||||
$doctor2_id = $this->factory->user->create( array( 'role' => 'doctor' ) );
|
||||
$patient2_id = $this->factory->user->create( array( 'role' => 'patient' ) );
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// Map users to different clinics
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_doctor_clinic_mappings', array( 'doctor_id' => $this->doctor_user, 'clinic_id' => $clinic1_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_doctor_clinic_mappings', array( 'doctor_id' => $doctor2_id, 'clinic_id' => $clinic2_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_patient_clinic_mappings', array( 'patient_id' => $this->patient_user, 'clinic_id' => $clinic1_id ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'kc_patient_clinic_mappings', array( 'patient_id' => $patient2_id, 'clinic_id' => $clinic2_id ) );
|
||||
|
||||
// Create appointments in both clinics
|
||||
$appointment1_id = $this->create_test_appointment( $clinic1_id, $this->doctor_user, $this->patient_user );
|
||||
$appointment2_id = $this->create_test_appointment( $clinic2_id, $doctor2_id, $patient2_id );
|
||||
|
||||
// TEST: Doctor 1 should only see clinic 1 data
|
||||
$doctor1_patients = $this->make_request( '/wp-json/kivicare/v1/patients', 'GET', array(), $this->doctor_user );
|
||||
$patients_data = $doctor1_patients->get_data()['data'];
|
||||
|
||||
foreach ( $patients_data as $patient ) {
|
||||
$this->assertEquals( $clinic1_id, $patient['clinic_id'], 'Doctor should only see patients from their clinic' );
|
||||
}
|
||||
|
||||
$doctor1_appointments = $this->make_request( '/wp-json/kivicare/v1/appointments', 'GET', array(), $this->doctor_user );
|
||||
$appointments_data = $doctor1_appointments->get_data()['data'];
|
||||
|
||||
foreach ( $appointments_data as $appointment ) {
|
||||
$this->assertEquals( $clinic1_id, $appointment['clinic_id'], 'Doctor should only see appointments from their clinic' );
|
||||
}
|
||||
|
||||
// TEST: Patient should only see own data
|
||||
$patient_appointments = $this->make_request( '/wp-json/kivicare/v1/appointments', 'GET', array(), $this->patient_user );
|
||||
$patient_appointments_data = $patient_appointments->get_data()['data'];
|
||||
|
||||
foreach ( $patient_appointments_data as $appointment ) {
|
||||
$this->assertEquals( $this->patient_user, $appointment['patient_id'], 'Patient should only see their own appointments' );
|
||||
}
|
||||
|
||||
// TEST: Administrator should see all data
|
||||
$admin_patients = $this->make_request( '/wp-json/kivicare/v1/patients', 'GET', array(), $this->admin_user );
|
||||
$all_patients_data = $admin_patients->get_data()['data'];
|
||||
|
||||
$clinic_ids = wp_list_pluck( $all_patients_data, 'clinic_id' );
|
||||
$this->assertContains( $clinic1_id, $clinic_ids );
|
||||
$this->assertContains( $clinic2_id, $clinic_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API key authentication and permissions.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_api_key_authentication_permissions() {
|
||||
// This test will fail initially as API key authentication isn't implemented
|
||||
$this->markTestIncomplete( 'API key authentication not implemented yet - TDD RED phase' );
|
||||
|
||||
// ARRANGE: Generate API keys for different purposes
|
||||
$api_keys = array(
|
||||
'read_only' => $this->generate_api_key( 'read_only', array( 'read_patients', 'read_appointments' ) ),
|
||||
'full_admin' => $this->generate_api_key( 'full_admin', array( 'all' ) ),
|
||||
'billing' => $this->generate_api_key( 'billing', array( 'read_bills', 'process_payments' ) ),
|
||||
);
|
||||
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
$appointment_id = $this->create_test_appointment( $clinic_id, $this->doctor_user, $this->patient_user );
|
||||
|
||||
// Test API key permissions
|
||||
$api_key_tests = array(
|
||||
array( 'key' => 'read_only', 'method' => 'GET', 'endpoint' => '/wp-json/kivicare/v1/patients', 'expected' => 200 ),
|
||||
array( 'key' => 'read_only', 'method' => 'POST', 'endpoint' => '/wp-json/kivicare/v1/patients', 'expected' => 403 ),
|
||||
array( 'key' => 'full_admin', 'method' => 'POST', 'endpoint' => '/wp-json/kivicare/v1/patients', 'expected' => 201 ),
|
||||
array( 'key' => 'billing', 'method' => 'GET', 'endpoint' => '/wp-json/kivicare/v1/bills', 'expected' => 200 ),
|
||||
array( 'key' => 'billing', 'method' => 'GET', 'endpoint' => '/wp-json/kivicare/v1/patients', 'expected' => 403 ),
|
||||
);
|
||||
|
||||
foreach ( $api_key_tests as $test ) {
|
||||
// Set API key in header
|
||||
$_SERVER['HTTP_X_API_KEY'] = $api_keys[ $test['key'] ];
|
||||
|
||||
$test_data = array();
|
||||
if ( $test['method'] === 'POST' && strpos( $test['endpoint'], 'patients' ) !== false ) {
|
||||
$test_data = array( 'display_name' => 'API Test Patient', 'user_email' => 'api@test.com', 'clinic_id' => $clinic_id );
|
||||
}
|
||||
|
||||
$response = $this->make_request( $test['endpoint'], $test['method'], $test_data );
|
||||
$this->assertRestResponse( $response, $test['expected'] );
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
unset( $_SERVER['HTTP_X_API_KEY'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test permission inheritance and role hierarchy.
|
||||
*
|
||||
* @test
|
||||
*/
|
||||
public function test_permission_inheritance_hierarchy() {
|
||||
// This test will fail initially as role hierarchy isn't implemented
|
||||
$this->markTestIncomplete( 'Permission inheritance not implemented yet - TDD RED phase' );
|
||||
|
||||
// Create custom role with specific capabilities
|
||||
add_role( 'clinic_manager', 'Clinic Manager', array(
|
||||
'read' => true,
|
||||
'manage_kivicare_api' => true,
|
||||
'kivicare_api_clinic_admin' => true,
|
||||
'kivicare_api_manage_doctors' => true,
|
||||
'kivicare_api_manage_patients' => true,
|
||||
'kivicare_api_view_reports' => true,
|
||||
));
|
||||
|
||||
$clinic_manager_id = $this->factory->user->create( array( 'role' => 'clinic_manager' ) );
|
||||
$clinic_id = $this->create_test_clinic();
|
||||
|
||||
// Test role hierarchy permissions
|
||||
$hierarchy_tests = array(
|
||||
// Clinic manager should have patient and doctor management access
|
||||
array( 'user' => $clinic_manager_id, 'endpoint' => '/wp-json/kivicare/v1/patients', 'method' => 'GET', 'expected' => 200 ),
|
||||
array( 'user' => $clinic_manager_id, 'endpoint' => '/wp-json/kivicare/v1/patients', 'method' => 'POST', 'expected' => 201 ),
|
||||
array( 'user' => $clinic_manager_id, 'endpoint' => '/wp-json/kivicare/v1/reports/clinic', 'method' => 'GET', 'expected' => 200 ),
|
||||
|
||||
// But should NOT have medical data access
|
||||
array( 'user' => $clinic_manager_id, 'endpoint' => '/wp-json/kivicare/v1/encounters', 'method' => 'GET', 'expected' => 403 ),
|
||||
array( 'user' => $clinic_manager_id, 'endpoint' => '/wp-json/kivicare/v1/encounters/1/prescriptions', 'method' => 'POST', 'expected' => 403 ),
|
||||
);
|
||||
|
||||
foreach ( $hierarchy_tests as $test ) {
|
||||
$test_data = array();
|
||||
if ( $test['method'] === 'POST' && strpos( $test['endpoint'], 'patients' ) !== false ) {
|
||||
$test_data = array( 'display_name' => 'Manager Test', 'user_email' => 'manager@test.com', 'clinic_id' => $clinic_id );
|
||||
}
|
||||
|
||||
$response = $this->make_request( $test['endpoint'], $test['method'], $test_data, $test['user'] );
|
||||
$this->assertRestResponse( $response, $test['expected'] );
|
||||
}
|
||||
|
||||
// Cleanup custom role
|
||||
remove_role( 'clinic_manager' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to generate API key (will be implemented).
|
||||
*
|
||||
* @param string $name Key name.
|
||||
* @param array $permissions Key permissions.
|
||||
* @return string API key.
|
||||
*/
|
||||
private function generate_api_key( $name, $permissions ) {
|
||||
// This would be implemented with actual API key generation
|
||||
return 'api_key_' . $name . '_' . wp_generate_password( 32, false );
|
||||
}
|
||||
}
|
||||
111
tests/mocks/mock-kivicare.php
Normal file
111
tests/mocks/mock-kivicare.php
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Mock KiviCare plugin functionality for testing.
|
||||
*
|
||||
* @package KiviCare_API\Tests\Mocks
|
||||
*/
|
||||
|
||||
// Exit if accessed directly.
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock KiviCare plugin activation check.
|
||||
*/
|
||||
function mock_kivicare_activation() {
|
||||
// Mock the KiviCare plugin as active
|
||||
add_filter( 'active_plugins', function( $plugins ) {
|
||||
$plugins[] = 'kivicare-clinic-&-patient-management-system/kivicare-clinic-&-patient-management-system.php';
|
||||
return $plugins;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock KiviCare user roles.
|
||||
*/
|
||||
function mock_kivicare_roles() {
|
||||
// Add KiviCare roles that don't exist in core WordPress
|
||||
add_role( 'doctor', 'Doctor', array(
|
||||
'read' => true,
|
||||
'manage_kivicare_api' => true,
|
||||
'kivicare_api_medical_access' => true,
|
||||
));
|
||||
|
||||
add_role( 'patient', 'Patient', array(
|
||||
'read' => true,
|
||||
'manage_kivicare_api' => true,
|
||||
'kivicare_api_patient_access' => true,
|
||||
));
|
||||
|
||||
add_role( 'kivicare_receptionist', 'Receptionist', array(
|
||||
'read' => true,
|
||||
'manage_kivicare_api' => true,
|
||||
'kivicare_api_reception_access' => true,
|
||||
));
|
||||
|
||||
// Add capabilities to administrator role
|
||||
$admin_role = get_role( 'administrator' );
|
||||
if ( $admin_role ) {
|
||||
$admin_role->add_cap( 'manage_kivicare_api' );
|
||||
$admin_role->add_cap( 'kivicare_api_full_access' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock KiviCare constants.
|
||||
*/
|
||||
function mock_kivicare_constants() {
|
||||
if ( ! defined( 'KIVI_CARE_VERSION' ) ) {
|
||||
define( 'KIVI_CARE_VERSION', '2.5.0' );
|
||||
}
|
||||
|
||||
if ( ! defined( 'KIVI_CARE_DIR' ) ) {
|
||||
define( 'KIVI_CARE_DIR', WP_PLUGIN_DIR . '/kivicare-clinic-&-patient-management-system/' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock KiviCare helper functions.
|
||||
*/
|
||||
if ( ! function_exists( 'kcGetDefaultClinicId' ) ) {
|
||||
/**
|
||||
* Mock function to get default clinic ID.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
function kcGetDefaultClinicId() {
|
||||
return get_option( 'kivicare_api_test_clinic_id', 1 );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'kcCheckPermission' ) ) {
|
||||
/**
|
||||
* Mock function to check KiviCare permissions.
|
||||
*
|
||||
* @param string $permission Permission to check.
|
||||
* @return bool
|
||||
*/
|
||||
function kcCheckPermission( $permission ) {
|
||||
return current_user_can( 'manage_kivicare_api' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize mock KiviCare functionality.
|
||||
*/
|
||||
function init_mock_kivicare() {
|
||||
mock_kivicare_activation();
|
||||
mock_kivicare_constants();
|
||||
|
||||
// Wait until init to add roles
|
||||
add_action( 'init', 'mock_kivicare_roles' );
|
||||
}
|
||||
|
||||
// Initialize mocks
|
||||
init_mock_kivicare();
|
||||
280
tests/setup/test-database.php
Normal file
280
tests/setup/test-database.php
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Descomplicar® Crescimento Digital
|
||||
* https://descomplicar.pt
|
||||
*/
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Test database setup for KiviCare API tests.
|
||||
*
|
||||
* @package KiviCare_API\Tests
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class to handle test database setup.
|
||||
*/
|
||||
class KiviCare_API_Test_Database {
|
||||
|
||||
/**
|
||||
* Create necessary KiviCare tables for testing.
|
||||
*/
|
||||
public static function create_tables() {
|
||||
global $wpdb;
|
||||
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
// Clinics table
|
||||
$table_name = $wpdb->prefix . 'kc_clinics';
|
||||
$sql = "CREATE TABLE $table_name (
|
||||
id bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
name varchar(191) NOT NULL,
|
||||
email varchar(191) DEFAULT NULL,
|
||||
telephone_no varchar(191) DEFAULT NULL,
|
||||
specialties longtext,
|
||||
address text,
|
||||
city varchar(191) DEFAULT NULL,
|
||||
state varchar(191) DEFAULT NULL,
|
||||
country varchar(191) DEFAULT NULL,
|
||||
postal_code varchar(191) DEFAULT NULL,
|
||||
status tinyint(1) DEFAULT 1,
|
||||
clinic_admin_id bigint(20) DEFAULT NULL,
|
||||
clinic_logo bigint(20) DEFAULT NULL,
|
||||
profile_image bigint(20) DEFAULT NULL,
|
||||
extra longtext,
|
||||
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY clinic_admin_id (clinic_admin_id),
|
||||
KEY status (status)
|
||||
) $charset_collate;";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta( $sql );
|
||||
|
||||
// Appointments table
|
||||
$table_name = $wpdb->prefix . 'kc_appointments';
|
||||
$sql = "CREATE TABLE $table_name (
|
||||
id bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
appointment_start_date date NOT NULL,
|
||||
appointment_start_time time NOT NULL,
|
||||
appointment_end_date date DEFAULT NULL,
|
||||
appointment_end_time time DEFAULT NULL,
|
||||
visit_type varchar(191) DEFAULT NULL,
|
||||
clinic_id bigint(20) NOT NULL,
|
||||
doctor_id bigint(20) NOT NULL,
|
||||
patient_id bigint(20) NOT NULL,
|
||||
description text,
|
||||
status tinyint(1) DEFAULT 1,
|
||||
appointment_report longtext,
|
||||
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY clinic_id (clinic_id),
|
||||
KEY doctor_id (doctor_id),
|
||||
KEY patient_id (patient_id),
|
||||
KEY appointment_date (appointment_start_date, appointment_start_time),
|
||||
KEY status (status)
|
||||
) $charset_collate;";
|
||||
|
||||
dbDelta( $sql );
|
||||
|
||||
// Patient encounters table
|
||||
$table_name = $wpdb->prefix . 'kc_patient_encounters';
|
||||
$sql = "CREATE TABLE $table_name (
|
||||
id bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
encounter_date date NOT NULL,
|
||||
clinic_id bigint(20) NOT NULL,
|
||||
doctor_id bigint(20) NOT NULL,
|
||||
patient_id bigint(20) NOT NULL,
|
||||
appointment_id bigint(20) DEFAULT NULL,
|
||||
description text,
|
||||
status tinyint(1) DEFAULT 1,
|
||||
added_by bigint(20) DEFAULT NULL,
|
||||
template_id bigint(20) DEFAULT NULL,
|
||||
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY clinic_id (clinic_id),
|
||||
KEY doctor_id (doctor_id),
|
||||
KEY patient_id (patient_id),
|
||||
KEY appointment_id (appointment_id),
|
||||
KEY encounter_date (encounter_date)
|
||||
) $charset_collate;";
|
||||
|
||||
dbDelta( $sql );
|
||||
|
||||
// Prescriptions table
|
||||
$table_name = $wpdb->prefix . 'kc_prescription';
|
||||
$sql = "CREATE TABLE $table_name (
|
||||
id bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
encounter_id bigint(20) NOT NULL,
|
||||
patient_id bigint(20) NOT NULL,
|
||||
name text NOT NULL,
|
||||
frequency varchar(199) DEFAULT NULL,
|
||||
duration varchar(199) DEFAULT NULL,
|
||||
instruction text,
|
||||
added_by bigint(20) DEFAULT NULL,
|
||||
is_from_template tinyint(1) DEFAULT 0,
|
||||
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY encounter_id (encounter_id),
|
||||
KEY patient_id (patient_id)
|
||||
) $charset_collate;";
|
||||
|
||||
dbDelta( $sql );
|
||||
|
||||
// Bills table
|
||||
$table_name = $wpdb->prefix . 'kc_bills';
|
||||
$sql = "CREATE TABLE $table_name (
|
||||
id bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
encounter_id bigint(20) DEFAULT NULL,
|
||||
appointment_id bigint(20) DEFAULT NULL,
|
||||
clinic_id bigint(20) NOT NULL,
|
||||
title varchar(191) DEFAULT NULL,
|
||||
total_amount varchar(50) DEFAULT NULL,
|
||||
discount varchar(50) DEFAULT '0',
|
||||
actual_amount varchar(50) DEFAULT NULL,
|
||||
status bigint(20) DEFAULT 1,
|
||||
payment_status varchar(10) DEFAULT 'unpaid',
|
||||
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY encounter_id (encounter_id),
|
||||
KEY appointment_id (appointment_id),
|
||||
KEY clinic_id (clinic_id),
|
||||
KEY payment_status (payment_status)
|
||||
) $charset_collate;";
|
||||
|
||||
dbDelta( $sql );
|
||||
|
||||
// Services table
|
||||
$table_name = $wpdb->prefix . 'kc_services';
|
||||
$sql = "CREATE TABLE $table_name (
|
||||
id bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
type varchar(191) DEFAULT 'system_service',
|
||||
name varchar(191) NOT NULL,
|
||||
price varchar(50) DEFAULT '0',
|
||||
status tinyint(1) DEFAULT 1,
|
||||
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY type (type),
|
||||
KEY status (status)
|
||||
) $charset_collate;";
|
||||
|
||||
dbDelta( $sql );
|
||||
|
||||
// Doctor clinic mappings
|
||||
$table_name = $wpdb->prefix . 'kc_doctor_clinic_mappings';
|
||||
$sql = "CREATE TABLE $table_name (
|
||||
id bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
doctor_id bigint(20) NOT NULL,
|
||||
clinic_id bigint(20) NOT NULL,
|
||||
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY doctor_clinic (doctor_id, clinic_id),
|
||||
KEY doctor_id (doctor_id),
|
||||
KEY clinic_id (clinic_id)
|
||||
) $charset_collate;";
|
||||
|
||||
dbDelta( $sql );
|
||||
|
||||
// Patient clinic mappings
|
||||
$table_name = $wpdb->prefix . 'kc_patient_clinic_mappings';
|
||||
$sql = "CREATE TABLE $table_name (
|
||||
id bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
patient_id bigint(20) NOT NULL,
|
||||
clinic_id bigint(20) NOT NULL,
|
||||
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY patient_clinic (patient_id, clinic_id),
|
||||
KEY patient_id (patient_id),
|
||||
KEY clinic_id (clinic_id)
|
||||
) $charset_collate;";
|
||||
|
||||
dbDelta( $sql );
|
||||
|
||||
// Appointment service mappings
|
||||
$table_name = $wpdb->prefix . 'kc_appointment_service_mapping';
|
||||
$sql = "CREATE TABLE $table_name (
|
||||
id bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
appointment_id bigint(20) NOT NULL,
|
||||
service_id bigint(20) NOT NULL,
|
||||
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY appointment_id (appointment_id),
|
||||
KEY service_id (service_id)
|
||||
) $charset_collate;";
|
||||
|
||||
dbDelta( $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert sample data for testing.
|
||||
*/
|
||||
public static function insert_sample_data() {
|
||||
global $wpdb;
|
||||
|
||||
// Create sample clinic
|
||||
$wpdb->insert(
|
||||
$wpdb->prefix . 'kc_clinics',
|
||||
array(
|
||||
'name' => 'Test Clinic',
|
||||
'email' => 'test@clinic.com',
|
||||
'telephone_no' => '+351912345678',
|
||||
'address' => 'Rua de Teste, 123',
|
||||
'city' => 'Lisboa',
|
||||
'state' => 'Lisboa',
|
||||
'country' => 'Portugal',
|
||||
'postal_code' => '1000-001',
|
||||
'status' => 1,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
)
|
||||
);
|
||||
|
||||
$clinic_id = $wpdb->insert_id;
|
||||
|
||||
// Create sample services
|
||||
$services = array(
|
||||
array( 'name' => 'General Consultation', 'price' => '50.00' ),
|
||||
array( 'name' => 'Blood Test', 'price' => '25.00' ),
|
||||
array( 'name' => 'X-Ray', 'price' => '75.00' ),
|
||||
);
|
||||
|
||||
foreach ( $services as $service ) {
|
||||
$wpdb->insert(
|
||||
$wpdb->prefix . 'kc_services',
|
||||
array(
|
||||
'name' => $service['name'],
|
||||
'price' => $service['price'],
|
||||
'status' => 1,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Store clinic ID for use in tests
|
||||
update_option( 'kivicare_api_test_clinic_id', $clinic_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up test data.
|
||||
*/
|
||||
public static function cleanup() {
|
||||
global $wpdb;
|
||||
|
||||
$tables = array(
|
||||
'kc_clinics',
|
||||
'kc_appointments',
|
||||
'kc_patient_encounters',
|
||||
'kc_prescription',
|
||||
'kc_bills',
|
||||
'kc_services',
|
||||
'kc_doctor_clinic_mappings',
|
||||
'kc_patient_clinic_mappings',
|
||||
'kc_appointment_service_mapping',
|
||||
);
|
||||
|
||||
foreach ( $tables as $table ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}$table" );
|
||||
}
|
||||
|
||||
delete_option( 'kivicare_api_test_clinic_id' );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user