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,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