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:
Emanuel Almeida
2025-09-12 01:27:29 +01:00
parent 30ad448ed3
commit 4a7b232f68
50 changed files with 513565 additions and 0 deletions

View 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;
}
}

File diff suppressed because it is too large Load Diff

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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' );
}
}

View 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 );
}
}
}
}
}

View 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 );
}
}

View 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' );
}
}

File diff suppressed because it is too large Load Diff

View 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}" );
}
}

View 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;
}
}

View 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' );
}
}

View 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
}
}

File diff suppressed because it is too large Load Diff