feat(Deployment): Integrate Ansible deployment via PHP deployment pipeline
- Create AnsibleDeployStage using framework's Process module for secure command execution - Integrate AnsibleDeployStage into DeploymentPipelineCommands for production deployments - Add force_deploy flag support in Ansible playbook to override stale locks - Use PHP deployment module as orchestrator (php console.php deploy:production) - Fix ErrorAggregationInitializer to use Environment class instead of $_ENV superglobal Architecture: - BuildStage → AnsibleDeployStage → HealthCheckStage for production - Process module provides timeout, error handling, and output capture - Ansible playbook supports rollback via rollback-git-based.yml - Zero-downtime deployments with health checks
This commit is contained in:
@@ -16,3 +16,12 @@ vendor
|
|||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Storage - Exclude from Docker build to allow Docker volume mounts
|
||||||
|
# Docker needs to create these directories fresh during volume mounting
|
||||||
|
# Exclude entire storage directory AND any symlinks that might point to it
|
||||||
|
storage/
|
||||||
|
storage
|
||||||
|
**/storage/sessions
|
||||||
|
**/storage/analytics
|
||||||
|
**/public/uploads
|
||||||
20
.env.example
20
.env.example
@@ -85,3 +85,23 @@ TIDAL_REDIRECT_URI=https://localhost/oauth/tidal/callback
|
|||||||
# Filesystem Performance (caching enabled by default)
|
# Filesystem Performance (caching enabled by default)
|
||||||
# Set to true only for debugging performance issues
|
# Set to true only for debugging performance issues
|
||||||
# FILESYSTEM_DISABLE_CACHE=false
|
# FILESYSTEM_DISABLE_CACHE=false
|
||||||
|
|
||||||
|
# ML Model Management Configuration (optional - defaults in MLConfig class)
|
||||||
|
# ML_MONITORING_ENABLED=true
|
||||||
|
# ML_DRIFT_THRESHOLD=0.15
|
||||||
|
# ML_PERFORMANCE_WINDOW_HOURS=24
|
||||||
|
# ML_AUTO_TUNING_ENABLED=false
|
||||||
|
# ML_PREDICTION_CACHE_TTL=3600
|
||||||
|
# ML_MODEL_CACHE_TTL=7200
|
||||||
|
# ML_BASELINE_UPDATE_INTERVAL=86400
|
||||||
|
# ML_MIN_PREDICTIONS_FOR_DRIFT=100
|
||||||
|
# ML_CONFIDENCE_ALERT_THRESHOLD=0.65
|
||||||
|
# ML_ACCURACY_ALERT_THRESHOLD=0.75
|
||||||
|
|
||||||
|
# WhatsApp Business API Configuration
|
||||||
|
# SECURITY: Replace with your actual WhatsApp Business API credentials
|
||||||
|
# Get credentials from: https://business.facebook.com/settings/whatsapp-business-accounts
|
||||||
|
WHATSAPP_ACCESS_TOKEN=your_whatsapp_access_token_here
|
||||||
|
WHATSAPP_PHONE_NUMBER_ID=107051338692505
|
||||||
|
WHATSAPP_BUSINESS_ACCOUNT_ID=your_business_account_id_here
|
||||||
|
WHATSAPP_API_VERSION=v18.0
|
||||||
383
deployment/infrastructure/playbooks/deploy-git-based.yml
Normal file
383
deployment/infrastructure/playbooks/deploy-git-based.yml
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
---
|
||||||
|
# Git-based Deployment Playbook with Releases/Symlink Pattern
|
||||||
|
# Implements production-ready deployment with zero-downtime and rollback support
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ansible-playbook -i inventories/production/hosts.yml playbooks/deploy-git-based.yml
|
||||||
|
# ansible-playbook -i inventories/production/hosts.yml playbooks/deploy-git-based.yml --extra-vars "git_branch=main"
|
||||||
|
# ansible-playbook -i inventories/production/hosts.yml playbooks/deploy-git-based.yml --extra-vars "release_tag=v1.0.0"
|
||||||
|
|
||||||
|
- name: Deploy Custom PHP Framework (Git-based with Releases)
|
||||||
|
hosts: web_servers
|
||||||
|
become: true
|
||||||
|
|
||||||
|
vars:
|
||||||
|
# Application configuration
|
||||||
|
app_name: michaelschiemer
|
||||||
|
app_user: deploy
|
||||||
|
app_group: deploy
|
||||||
|
|
||||||
|
# Deployment paths
|
||||||
|
app_base_path: "/var/www/{{ app_name }}"
|
||||||
|
releases_path: "{{ app_base_path }}/releases"
|
||||||
|
shared_path: "{{ app_base_path }}/shared"
|
||||||
|
current_path: "{{ app_base_path }}/current"
|
||||||
|
|
||||||
|
# Git configuration
|
||||||
|
git_repo: "https://github.com/michaelschiemer/michaelschiemer.git"
|
||||||
|
git_branch: "{{ release_tag | default('main') }}"
|
||||||
|
|
||||||
|
# Release configuration
|
||||||
|
release_timestamp: "{{ ansible_date_time.epoch }}"
|
||||||
|
release_name: "{{ release_tag | default(release_timestamp) }}"
|
||||||
|
release_path: "{{ releases_path }}/{{ release_name }}"
|
||||||
|
|
||||||
|
# Deployment settings
|
||||||
|
keep_releases: 5
|
||||||
|
composer_install_flags: "--no-dev --optimize-autoloader --no-interaction"
|
||||||
|
|
||||||
|
# Shared directories and files
|
||||||
|
shared_dirs:
|
||||||
|
- storage/logs
|
||||||
|
- storage/cache
|
||||||
|
- storage/sessions
|
||||||
|
- storage/uploads
|
||||||
|
- public/uploads
|
||||||
|
|
||||||
|
shared_files:
|
||||||
|
- .env.production
|
||||||
|
|
||||||
|
pre_tasks:
|
||||||
|
- name: Check if deployment lock exists
|
||||||
|
stat:
|
||||||
|
path: "{{ app_base_path }}/.deploy.lock"
|
||||||
|
register: deploy_lock
|
||||||
|
|
||||||
|
- name: Fail if deployment is already in progress
|
||||||
|
fail:
|
||||||
|
msg: "Deployment already in progress. Lock file exists: {{ app_base_path }}/.deploy.lock"
|
||||||
|
when: deploy_lock.stat.exists
|
||||||
|
|
||||||
|
- name: Create deployment lock
|
||||||
|
file:
|
||||||
|
path: "{{ app_base_path }}/.deploy.lock"
|
||||||
|
state: touch
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
mode: '0644'
|
||||||
|
|
||||||
|
- name: Log deployment start
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] Deployment started - Release: {{ release_name }} - User: {{ ansible_user_id }}"
|
||||||
|
create: yes
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
# ==========================================
|
||||||
|
# 1. Directory Structure Setup
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Create base application directory
|
||||||
|
file:
|
||||||
|
path: "{{ app_base_path }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Create releases directory
|
||||||
|
file:
|
||||||
|
path: "{{ releases_path }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Create shared directory
|
||||||
|
file:
|
||||||
|
path: "{{ shared_path }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Create shared subdirectories
|
||||||
|
file:
|
||||||
|
path: "{{ shared_path }}/{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
mode: '0755'
|
||||||
|
loop: "{{ shared_dirs }}"
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 2. Git Repository Clone
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Clone repository to new release directory
|
||||||
|
git:
|
||||||
|
repo: "{{ git_repo }}"
|
||||||
|
dest: "{{ release_path }}"
|
||||||
|
version: "{{ git_branch }}"
|
||||||
|
force: yes
|
||||||
|
depth: 1
|
||||||
|
become_user: "{{ app_user }}"
|
||||||
|
register: git_clone
|
||||||
|
|
||||||
|
- name: Get current commit hash
|
||||||
|
command: git rev-parse HEAD
|
||||||
|
args:
|
||||||
|
chdir: "{{ release_path }}"
|
||||||
|
register: commit_hash
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Log commit hash
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] Commit: {{ commit_hash.stdout }}"
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 3. Shared Files/Directories Symlinks
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Remove shared directories from release (they will be symlinked)
|
||||||
|
file:
|
||||||
|
path: "{{ release_path }}/{{ item }}"
|
||||||
|
state: absent
|
||||||
|
loop: "{{ shared_dirs }}"
|
||||||
|
|
||||||
|
- name: Create symlinks for shared directories
|
||||||
|
file:
|
||||||
|
src: "{{ shared_path }}/{{ item }}"
|
||||||
|
dest: "{{ release_path }}/{{ item }}"
|
||||||
|
state: link
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
loop: "{{ shared_dirs }}"
|
||||||
|
|
||||||
|
- name: Create symlinks for shared files
|
||||||
|
file:
|
||||||
|
src: "{{ shared_path }}/{{ item }}"
|
||||||
|
dest: "{{ release_path }}/{{ item }}"
|
||||||
|
state: link
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
loop: "{{ shared_files }}"
|
||||||
|
when: shared_files | length > 0
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 4. Dependencies Installation
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
composer:
|
||||||
|
command: install
|
||||||
|
arguments: "{{ composer_install_flags }}"
|
||||||
|
working_dir: "{{ release_path }}"
|
||||||
|
become_user: "{{ app_user }}"
|
||||||
|
environment:
|
||||||
|
COMPOSER_HOME: "/home/{{ app_user }}/.composer"
|
||||||
|
|
||||||
|
- name: Check if package.json exists
|
||||||
|
stat:
|
||||||
|
path: "{{ release_path }}/package.json"
|
||||||
|
register: package_json
|
||||||
|
|
||||||
|
- name: Install NPM dependencies and build assets
|
||||||
|
block:
|
||||||
|
- name: Install NPM dependencies
|
||||||
|
npm:
|
||||||
|
path: "{{ release_path }}"
|
||||||
|
state: present
|
||||||
|
production: yes
|
||||||
|
become_user: "{{ app_user }}"
|
||||||
|
|
||||||
|
- name: Build production assets
|
||||||
|
command: npm run build
|
||||||
|
args:
|
||||||
|
chdir: "{{ release_path }}"
|
||||||
|
become_user: "{{ app_user }}"
|
||||||
|
when: package_json.stat.exists
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 5. File Permissions
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Set correct ownership for release
|
||||||
|
file:
|
||||||
|
path: "{{ release_path }}"
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
recurse: yes
|
||||||
|
|
||||||
|
- name: Make console script executable
|
||||||
|
file:
|
||||||
|
path: "{{ release_path }}/console.php"
|
||||||
|
mode: '0755'
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 6. Database Migrations (Optional)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Run database migrations
|
||||||
|
command: php console.php db:migrate --no-interaction
|
||||||
|
args:
|
||||||
|
chdir: "{{ release_path }}"
|
||||||
|
become_user: "{{ app_user }}"
|
||||||
|
when: run_migrations | default(false) | bool
|
||||||
|
register: migrations_result
|
||||||
|
|
||||||
|
- name: Log migration result
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] Migrations: {{ migrations_result.stdout | default('skipped') }}"
|
||||||
|
when: run_migrations | default(false) | bool
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 7. Symlink Switch (Zero-Downtime)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Get current release (before switch)
|
||||||
|
stat:
|
||||||
|
path: "{{ current_path }}"
|
||||||
|
register: current_release_before
|
||||||
|
|
||||||
|
- name: Store previous release path for rollback
|
||||||
|
set_fact:
|
||||||
|
previous_release: "{{ current_release_before.stat.lnk_source | default('none') }}"
|
||||||
|
|
||||||
|
- name: Switch current symlink to new release (atomic operation)
|
||||||
|
file:
|
||||||
|
src: "{{ release_path }}"
|
||||||
|
dest: "{{ current_path }}"
|
||||||
|
state: link
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
force: yes
|
||||||
|
|
||||||
|
- name: Log symlink switch
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] Symlink switched: {{ current_path }} -> {{ release_path }}"
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 8. Health Checks
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Wait for application to be ready
|
||||||
|
wait_for:
|
||||||
|
timeout: 10
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: Health check - Summary endpoint
|
||||||
|
uri:
|
||||||
|
url: "http://{{ ansible_host }}/health/summary"
|
||||||
|
method: GET
|
||||||
|
return_content: yes
|
||||||
|
status_code: 200
|
||||||
|
register: health_check
|
||||||
|
retries: 3
|
||||||
|
delay: 5
|
||||||
|
until: health_check.status == 200
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Log health check result
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] Health check: {{ health_check.status | default('FAILED') }}"
|
||||||
|
|
||||||
|
- name: Rollback on health check failure
|
||||||
|
block:
|
||||||
|
- name: Switch symlink back to previous release
|
||||||
|
file:
|
||||||
|
src: "{{ previous_release }}"
|
||||||
|
dest: "{{ current_path }}"
|
||||||
|
state: link
|
||||||
|
force: yes
|
||||||
|
when: previous_release != 'none'
|
||||||
|
|
||||||
|
- name: Remove failed release
|
||||||
|
file:
|
||||||
|
path: "{{ release_path }}"
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Log rollback
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] ROLLBACK: Health check failed, reverted to {{ previous_release }}"
|
||||||
|
|
||||||
|
- name: Fail deployment
|
||||||
|
fail:
|
||||||
|
msg: "Deployment failed - health check returned {{ health_check.status }}. Rolled back to previous release."
|
||||||
|
when: health_check.status != 200
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 9. Cleanup Old Releases
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Get list of all releases
|
||||||
|
find:
|
||||||
|
paths: "{{ releases_path }}"
|
||||||
|
file_type: directory
|
||||||
|
register: all_releases
|
||||||
|
|
||||||
|
- name: Sort releases by creation time
|
||||||
|
set_fact:
|
||||||
|
sorted_releases: "{{ all_releases.files | sort(attribute='ctime', reverse=true) }}"
|
||||||
|
|
||||||
|
- name: Remove old releases (keep last {{ keep_releases }})
|
||||||
|
file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
state: absent
|
||||||
|
loop: "{{ sorted_releases[keep_releases:] }}"
|
||||||
|
when: sorted_releases | length > keep_releases
|
||||||
|
|
||||||
|
- name: Log cleanup
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] Cleanup: Kept {{ [sorted_releases | length, keep_releases] | min }} releases, removed {{ [sorted_releases | length - keep_releases, 0] | max }}"
|
||||||
|
|
||||||
|
post_tasks:
|
||||||
|
- name: Cleanup and logging
|
||||||
|
block:
|
||||||
|
- name: Remove deployment lock
|
||||||
|
file:
|
||||||
|
path: "{{ app_base_path }}/.deploy.lock"
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Log deployment completion
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] Deployment completed successfully - Release: {{ release_name }}"
|
||||||
|
|
||||||
|
- name: Display deployment summary
|
||||||
|
debug:
|
||||||
|
msg:
|
||||||
|
- "=========================================="
|
||||||
|
- "Deployment Summary"
|
||||||
|
- "=========================================="
|
||||||
|
- "Release: {{ release_name }}"
|
||||||
|
- "Commit: {{ commit_hash.stdout }}"
|
||||||
|
- "Path: {{ release_path }}"
|
||||||
|
- "Current: {{ current_path }}"
|
||||||
|
- "Health Check: {{ health_check.status | default('N/A') }}"
|
||||||
|
- "Previous Release: {{ previous_release }}"
|
||||||
|
- "=========================================="
|
||||||
|
|
||||||
|
rescue:
|
||||||
|
- name: Remove deployment lock on failure
|
||||||
|
file:
|
||||||
|
path: "{{ app_base_path }}/.deploy.lock"
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Log deployment failure
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] DEPLOYMENT FAILED - Release: {{ release_name }}"
|
||||||
|
|
||||||
|
- name: Fail with error message
|
||||||
|
fail:
|
||||||
|
msg: "Deployment failed. Check {{ app_base_path }}/deploy.log for details."
|
||||||
472
deployment/infrastructure/playbooks/deploy-rsync-based.yml
Normal file
472
deployment/infrastructure/playbooks/deploy-rsync-based.yml
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
---
|
||||||
|
# Rsync-based Deployment Playbook with Releases/Symlink Pattern
|
||||||
|
# Implements production-ready deployment with zero-downtime and rollback support
|
||||||
|
# No GitHub dependency - deploys directly from local machine
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ansible-playbook -i inventories/production/hosts.yml playbooks/deploy-rsync-based.yml
|
||||||
|
# ansible-playbook -i inventories/production/hosts.yml playbooks/deploy-rsync-based.yml --extra-vars "release_tag=v1.0.0"
|
||||||
|
|
||||||
|
- name: Deploy Custom PHP Framework (Rsync-based with Releases)
|
||||||
|
hosts: web_servers
|
||||||
|
become: true
|
||||||
|
|
||||||
|
vars:
|
||||||
|
# Application configuration
|
||||||
|
app_name: michaelschiemer
|
||||||
|
app_user: deploy
|
||||||
|
app_group: deploy
|
||||||
|
|
||||||
|
# Deployment paths
|
||||||
|
app_base_path: "/home/{{ app_user }}/{{ app_name }}"
|
||||||
|
releases_path: "{{ app_base_path }}/releases"
|
||||||
|
shared_path: "{{ app_base_path }}/shared"
|
||||||
|
current_path: "{{ app_base_path }}/current"
|
||||||
|
|
||||||
|
# Local source directory (project root on your machine)
|
||||||
|
local_project_path: "{{ playbook_dir }}/../../.."
|
||||||
|
|
||||||
|
# Release configuration
|
||||||
|
release_timestamp: "{{ ansible_date_time.epoch }}"
|
||||||
|
release_name: "{{ release_tag | default(release_timestamp) }}"
|
||||||
|
release_path: "{{ releases_path }}/{{ release_name }}"
|
||||||
|
|
||||||
|
# Deployment settings
|
||||||
|
keep_releases: 5
|
||||||
|
composer_install_flags: "--no-dev --optimize-autoloader --no-interaction"
|
||||||
|
|
||||||
|
# Shared directories and files
|
||||||
|
# Shared directories that need symlinks
|
||||||
|
# NOTE: storage/logs, storage/cache, storage/uploads are handled by Docker volumes
|
||||||
|
shared_dirs:
|
||||||
|
- storage/sessions
|
||||||
|
- public/uploads
|
||||||
|
|
||||||
|
shared_files:
|
||||||
|
- .env.production
|
||||||
|
|
||||||
|
# Rsync exclusions
|
||||||
|
rsync_excludes:
|
||||||
|
- .git/
|
||||||
|
- .github/
|
||||||
|
- node_modules/
|
||||||
|
- .env
|
||||||
|
- .env.local
|
||||||
|
- .env.development
|
||||||
|
- storage/
|
||||||
|
- public/uploads/
|
||||||
|
- tests/
|
||||||
|
- .idea/
|
||||||
|
- .vscode/
|
||||||
|
- "*.log"
|
||||||
|
- .DS_Store
|
||||||
|
- deployment/
|
||||||
|
- database.sqlite
|
||||||
|
- "*.cache"
|
||||||
|
- .php-cs-fixer.cache
|
||||||
|
- var/cache/
|
||||||
|
- var/logs/
|
||||||
|
|
||||||
|
pre_tasks:
|
||||||
|
- name: Install Composer dependencies locally before deployment
|
||||||
|
local_action:
|
||||||
|
module: command
|
||||||
|
cmd: composer install {{ composer_install_flags }}
|
||||||
|
chdir: "{{ local_project_path }}"
|
||||||
|
become: false
|
||||||
|
|
||||||
|
- name: Build NPM assets locally before deployment
|
||||||
|
local_action:
|
||||||
|
module: command
|
||||||
|
cmd: npm run build
|
||||||
|
chdir: "{{ local_project_path }}"
|
||||||
|
become: false
|
||||||
|
- name: Check if deployment lock exists
|
||||||
|
stat:
|
||||||
|
path: "{{ app_base_path }}/.deploy.lock"
|
||||||
|
register: deploy_lock
|
||||||
|
|
||||||
|
- name: Remove stale deployment lock if force flag is set
|
||||||
|
file:
|
||||||
|
path: "{{ app_base_path }}/.deploy.lock"
|
||||||
|
state: absent
|
||||||
|
when: deploy_lock.stat.exists and (force_deploy | default(false))
|
||||||
|
|
||||||
|
- name: Fail if deployment is already in progress (without force)
|
||||||
|
fail:
|
||||||
|
msg: "Deployment already in progress. Lock file exists: {{ app_base_path }}/.deploy.lock. Use --extra-vars 'force_deploy=true' to override."
|
||||||
|
when: deploy_lock.stat.exists and not (force_deploy | default(false))
|
||||||
|
|
||||||
|
- name: Create deployment lock
|
||||||
|
file:
|
||||||
|
path: "{{ app_base_path }}/.deploy.lock"
|
||||||
|
state: touch
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
mode: '0644'
|
||||||
|
|
||||||
|
- name: Log deployment start
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] Deployment started - Release: {{ release_name }} - User: {{ ansible_user_id }}"
|
||||||
|
create: yes
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
# ==========================================
|
||||||
|
# 1. Directory Structure Setup
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Create base application directory
|
||||||
|
file:
|
||||||
|
path: "{{ app_base_path }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Create releases directory
|
||||||
|
file:
|
||||||
|
path: "{{ releases_path }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Create shared directory
|
||||||
|
file:
|
||||||
|
path: "{{ shared_path }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Create shared subdirectories
|
||||||
|
file:
|
||||||
|
path: "{{ shared_path }}/{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
mode: '0755'
|
||||||
|
loop: "{{ shared_dirs }}"
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 2. Rsync Application Code to New Release
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Create new release directory
|
||||||
|
file:
|
||||||
|
path: "{{ release_path }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Sync application code to new release via rsync
|
||||||
|
synchronize:
|
||||||
|
src: "{{ local_project_path }}/"
|
||||||
|
dest: "{{ release_path }}/"
|
||||||
|
delete: yes
|
||||||
|
recursive: yes
|
||||||
|
rsync_opts: "{{ rsync_excludes | map('regex_replace', '^(.*)$', '--exclude=\\1') | list }}"
|
||||||
|
private_key: "{{ ansible_ssh_private_key_file }}"
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
|
||||||
|
- name: Set correct ownership for release
|
||||||
|
file:
|
||||||
|
path: "{{ release_path }}"
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
recurse: yes
|
||||||
|
|
||||||
|
- name: Get local git commit hash (if available)
|
||||||
|
command: git rev-parse HEAD
|
||||||
|
args:
|
||||||
|
chdir: "{{ local_project_path }}"
|
||||||
|
register: commit_hash
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Log commit hash
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] Commit: {{ commit_hash.stdout | default('N/A - not a git repository') }}"
|
||||||
|
when: commit_hash.rc == 0
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 3. Shared Files/Directories Symlinks
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Remove shared directories from release (they will be symlinked)
|
||||||
|
file:
|
||||||
|
path: "{{ release_path }}/{{ item }}"
|
||||||
|
state: absent
|
||||||
|
loop: "{{ shared_dirs }}"
|
||||||
|
|
||||||
|
- name: Create parent directories for symlinks
|
||||||
|
file:
|
||||||
|
path: "{{ release_path }}/{{ item | dirname }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
mode: '0755'
|
||||||
|
loop: "{{ shared_dirs }}"
|
||||||
|
# Skip if dirname is current directory ('.')
|
||||||
|
when: (item | dirname) != '.'
|
||||||
|
|
||||||
|
- name: Create symlinks for shared directories
|
||||||
|
file:
|
||||||
|
src: "{{ shared_path }}/{{ item }}"
|
||||||
|
dest: "{{ release_path }}/{{ item }}"
|
||||||
|
state: link
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
force: yes
|
||||||
|
loop: "{{ shared_dirs }}"
|
||||||
|
|
||||||
|
- name: Remove .env.production from release (will be symlinked)
|
||||||
|
file:
|
||||||
|
path: "{{ release_path }}/.env.production"
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Create symlink for .env.production
|
||||||
|
file:
|
||||||
|
src: "{{ shared_path }}/.env.production"
|
||||||
|
dest: "{{ release_path }}/.env.production"
|
||||||
|
state: link
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
force: yes
|
||||||
|
|
||||||
|
- name: Create .env symlink with relative path to shared .env.production for Docker container access
|
||||||
|
file:
|
||||||
|
src: "../../shared/.env.production"
|
||||||
|
dest: "{{ release_path }}/.env"
|
||||||
|
state: link
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
force: yes
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 4. Dependencies Installation
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# Composer dependencies and NPM assets are already built locally and rsync'd
|
||||||
|
# No need to run composer install or npm build on the server
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 5. File Permissions
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Make console script executable
|
||||||
|
file:
|
||||||
|
path: "{{ release_path }}/console.php"
|
||||||
|
mode: '0755'
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 6. Database Migrations (Optional)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Run database migrations
|
||||||
|
command: php console.php db:migrate --no-interaction
|
||||||
|
args:
|
||||||
|
chdir: "{{ release_path }}"
|
||||||
|
become_user: "{{ app_user }}"
|
||||||
|
when: run_migrations | default(false) | bool
|
||||||
|
register: migrations_result
|
||||||
|
|
||||||
|
- name: Log migration result
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] Migrations: {{ migrations_result.stdout | default('skipped') }}"
|
||||||
|
when: run_migrations | default(false) | bool
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 7. Prepare for Deployment
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Get current release (before switch)
|
||||||
|
stat:
|
||||||
|
path: "{{ current_path }}"
|
||||||
|
register: current_release_before
|
||||||
|
|
||||||
|
- name: Stop existing Docker containers (if any)
|
||||||
|
command: docker compose down
|
||||||
|
args:
|
||||||
|
chdir: "{{ current_path }}"
|
||||||
|
become_user: "{{ app_user }}"
|
||||||
|
when: current_release_before.stat.exists
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 8. Symlink Switch (Zero-Downtime)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Store previous release path for rollback
|
||||||
|
set_fact:
|
||||||
|
previous_release: "{{ current_release_before.stat.lnk_source | default('none') }}"
|
||||||
|
|
||||||
|
- name: Switch current symlink to new release (atomic operation)
|
||||||
|
file:
|
||||||
|
src: "{{ release_path }}"
|
||||||
|
dest: "{{ current_path }}"
|
||||||
|
state: link
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
force: yes
|
||||||
|
|
||||||
|
- name: Log symlink switch
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] Symlink switched: {{ current_path }} -> {{ release_path }}"
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 9. Start Docker Containers
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Start Docker containers with new release
|
||||||
|
command: docker compose -f docker-compose.yml -f docker-compose.production.yml up -d --build
|
||||||
|
args:
|
||||||
|
chdir: "{{ current_path }}"
|
||||||
|
become_user: "{{ app_user }}"
|
||||||
|
|
||||||
|
- name: Wait for containers to be ready
|
||||||
|
pause:
|
||||||
|
seconds: 15
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 10. Health Checks
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Wait for application to be ready
|
||||||
|
wait_for:
|
||||||
|
timeout: 10
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: Health check - Summary endpoint
|
||||||
|
uri:
|
||||||
|
url: "http://{{ ansible_host }}/health/summary"
|
||||||
|
method: GET
|
||||||
|
return_content: yes
|
||||||
|
status_code: 200
|
||||||
|
register: health_check
|
||||||
|
retries: 3
|
||||||
|
delay: 5
|
||||||
|
until: health_check.status == 200
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Log health check result
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] Health check: {{ health_check.status | default('FAILED') }}"
|
||||||
|
|
||||||
|
- name: Rollback on health check failure
|
||||||
|
block:
|
||||||
|
- name: Stop failed release containers
|
||||||
|
command: docker compose down
|
||||||
|
args:
|
||||||
|
chdir: "{{ current_path }}"
|
||||||
|
become_user: "{{ app_user }}"
|
||||||
|
|
||||||
|
- name: Switch symlink back to previous release
|
||||||
|
file:
|
||||||
|
src: "{{ previous_release }}"
|
||||||
|
dest: "{{ current_path }}"
|
||||||
|
state: link
|
||||||
|
force: yes
|
||||||
|
when: previous_release != 'none'
|
||||||
|
|
||||||
|
- name: Start previous release containers
|
||||||
|
command: docker compose -f docker-compose.yml -f docker-compose.production.yml up -d
|
||||||
|
args:
|
||||||
|
chdir: "{{ current_path }}"
|
||||||
|
become_user: "{{ app_user }}"
|
||||||
|
when: previous_release != 'none'
|
||||||
|
|
||||||
|
- name: Remove failed release
|
||||||
|
file:
|
||||||
|
path: "{{ release_path }}"
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Log rollback
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] ROLLBACK: Health check failed, reverted to {{ previous_release }}"
|
||||||
|
|
||||||
|
- name: Fail deployment
|
||||||
|
fail:
|
||||||
|
msg: "Deployment failed - health check returned {{ health_check.status }}. Rolled back to previous release."
|
||||||
|
when: health_check.status != 200
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 11. Cleanup Old Releases
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Get list of all releases
|
||||||
|
find:
|
||||||
|
paths: "{{ releases_path }}"
|
||||||
|
file_type: directory
|
||||||
|
register: all_releases
|
||||||
|
|
||||||
|
- name: Sort releases by creation time
|
||||||
|
set_fact:
|
||||||
|
sorted_releases: "{{ all_releases.files | sort(attribute='ctime', reverse=true) }}"
|
||||||
|
|
||||||
|
- name: Remove old releases (keep last {{ keep_releases }})
|
||||||
|
file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
state: absent
|
||||||
|
loop: "{{ sorted_releases[keep_releases:] }}"
|
||||||
|
when: sorted_releases | length > keep_releases
|
||||||
|
|
||||||
|
- name: Log cleanup
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] Cleanup: Kept {{ [sorted_releases | length, keep_releases] | min }} releases, removed {{ [sorted_releases | length - keep_releases, 0] | max }}"
|
||||||
|
|
||||||
|
post_tasks:
|
||||||
|
- name: Cleanup and logging
|
||||||
|
block:
|
||||||
|
- name: Remove deployment lock
|
||||||
|
file:
|
||||||
|
path: "{{ app_base_path }}/.deploy.lock"
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Log deployment completion
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] Deployment completed successfully - Release: {{ release_name }}"
|
||||||
|
|
||||||
|
- name: Display deployment summary
|
||||||
|
debug:
|
||||||
|
msg:
|
||||||
|
- "=========================================="
|
||||||
|
- "Deployment Summary"
|
||||||
|
- "=========================================="
|
||||||
|
- "Release: {{ release_name }}"
|
||||||
|
- "Commit: {{ commit_hash.stdout | default('N/A') }}"
|
||||||
|
- "Path: {{ release_path }}"
|
||||||
|
- "Current: {{ current_path }}"
|
||||||
|
- "Health Check: {{ health_check.status | default('N/A') }}"
|
||||||
|
- "Previous Release: {{ previous_release }}"
|
||||||
|
- "=========================================="
|
||||||
|
|
||||||
|
rescue:
|
||||||
|
- name: Remove deployment lock on failure
|
||||||
|
file:
|
||||||
|
path: "{{ app_base_path }}/.deploy.lock"
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Log deployment failure
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] DEPLOYMENT FAILED - Release: {{ release_name }}"
|
||||||
|
|
||||||
|
- name: Fail with error message
|
||||||
|
fail:
|
||||||
|
msg: "Deployment failed. Check {{ app_base_path }}/deploy.log for details."
|
||||||
142
deployment/infrastructure/playbooks/rollback-git-based.yml
Normal file
142
deployment/infrastructure/playbooks/rollback-git-based.yml
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
# Git-based Rollback Playbook
|
||||||
|
# Rolls back to the previous release by switching the symlink
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ansible-playbook -i inventories/production/hosts.yml playbooks/rollback-git-based.yml
|
||||||
|
# ansible-playbook -i inventories/production/hosts.yml playbooks/rollback-git-based.yml --extra-vars "rollback_to=20241025123456"
|
||||||
|
|
||||||
|
- name: Rollback Custom PHP Framework (Git-based)
|
||||||
|
hosts: web_servers
|
||||||
|
become: true
|
||||||
|
|
||||||
|
vars:
|
||||||
|
app_name: michaelschiemer
|
||||||
|
app_user: deploy
|
||||||
|
app_group: deploy
|
||||||
|
app_base_path: "/var/www/{{ app_name }}"
|
||||||
|
releases_path: "{{ app_base_path }}/releases"
|
||||||
|
current_path: "{{ app_base_path }}/current"
|
||||||
|
|
||||||
|
pre_tasks:
|
||||||
|
- name: Check if deployment lock exists
|
||||||
|
stat:
|
||||||
|
path: "{{ app_base_path }}/.deploy.lock"
|
||||||
|
register: deploy_lock
|
||||||
|
|
||||||
|
- name: Fail if deployment is in progress
|
||||||
|
fail:
|
||||||
|
msg: "Cannot rollback - deployment in progress"
|
||||||
|
when: deploy_lock.stat.exists
|
||||||
|
|
||||||
|
- name: Create rollback lock
|
||||||
|
file:
|
||||||
|
path: "{{ app_base_path }}/.rollback.lock"
|
||||||
|
state: touch
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Get current release
|
||||||
|
stat:
|
||||||
|
path: "{{ current_path }}"
|
||||||
|
register: current_release
|
||||||
|
|
||||||
|
- name: Fail if no current release exists
|
||||||
|
fail:
|
||||||
|
msg: "No current release found at {{ current_path }}"
|
||||||
|
when: not current_release.stat.exists
|
||||||
|
|
||||||
|
- name: Get list of all releases
|
||||||
|
find:
|
||||||
|
paths: "{{ releases_path }}"
|
||||||
|
file_type: directory
|
||||||
|
register: all_releases
|
||||||
|
|
||||||
|
- name: Sort releases by creation time (newest first)
|
||||||
|
set_fact:
|
||||||
|
sorted_releases: "{{ all_releases.files | sort(attribute='ctime', reverse=true) }}"
|
||||||
|
|
||||||
|
- name: Determine target release for rollback
|
||||||
|
set_fact:
|
||||||
|
target_release: "{{ rollback_to if rollback_to is defined else sorted_releases[1].path }}"
|
||||||
|
|
||||||
|
- name: Verify target release exists
|
||||||
|
stat:
|
||||||
|
path: "{{ target_release }}"
|
||||||
|
register: target_release_stat
|
||||||
|
|
||||||
|
- name: Fail if target release doesn't exist
|
||||||
|
fail:
|
||||||
|
msg: "Target release not found: {{ target_release }}"
|
||||||
|
when: not target_release_stat.stat.exists
|
||||||
|
|
||||||
|
- name: Display rollback information
|
||||||
|
debug:
|
||||||
|
msg:
|
||||||
|
- "Current release: {{ current_release.stat.lnk_source }}"
|
||||||
|
- "Rolling back to: {{ target_release }}"
|
||||||
|
|
||||||
|
- name: Switch symlink to previous release
|
||||||
|
file:
|
||||||
|
src: "{{ target_release }}"
|
||||||
|
dest: "{{ current_path }}"
|
||||||
|
state: link
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_group }}"
|
||||||
|
force: yes
|
||||||
|
|
||||||
|
- name: Wait for application to be ready
|
||||||
|
wait_for:
|
||||||
|
timeout: 5
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: Health check after rollback
|
||||||
|
uri:
|
||||||
|
url: "http://{{ ansible_host }}/health/summary"
|
||||||
|
method: GET
|
||||||
|
return_content: yes
|
||||||
|
status_code: 200
|
||||||
|
register: health_check
|
||||||
|
retries: 3
|
||||||
|
delay: 5
|
||||||
|
until: health_check.status == 200
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Log rollback
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] ROLLBACK: {{ current_release.stat.lnk_source }} -> {{ target_release }} - Health: {{ health_check.status | default('FAILED') }}"
|
||||||
|
create: yes
|
||||||
|
|
||||||
|
- name: Display rollback result
|
||||||
|
debug:
|
||||||
|
msg:
|
||||||
|
- "=========================================="
|
||||||
|
- "Rollback completed"
|
||||||
|
- "Previous: {{ current_release.stat.lnk_source }}"
|
||||||
|
- "Current: {{ target_release }}"
|
||||||
|
- "Health check: {{ health_check.status | default('FAILED') }}"
|
||||||
|
- "=========================================="
|
||||||
|
|
||||||
|
post_tasks:
|
||||||
|
- name: Remove rollback lock
|
||||||
|
file:
|
||||||
|
path: "{{ app_base_path }}/.rollback.lock"
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
rescue:
|
||||||
|
- name: Remove rollback lock on failure
|
||||||
|
file:
|
||||||
|
path: "{{ app_base_path }}/.rollback.lock"
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Log rollback failure
|
||||||
|
lineinfile:
|
||||||
|
path: "{{ app_base_path }}/deploy.log"
|
||||||
|
line: "[{{ ansible_date_time.iso8601 }}] ROLLBACK FAILED"
|
||||||
|
create: yes
|
||||||
|
|
||||||
|
- name: Fail with error message
|
||||||
|
fail:
|
||||||
|
msg: "Rollback failed"
|
||||||
170
deployment/infrastructure/playbooks/setup-docker.yml
Normal file
170
deployment/infrastructure/playbooks/setup-docker.yml
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
# Docker Setup Playbook
|
||||||
|
# Ensures Docker and Docker Compose are installed and configured
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ansible-playbook -i inventories/production/hosts.yml playbooks/setup-docker.yml
|
||||||
|
|
||||||
|
- name: Setup Docker for Production
|
||||||
|
hosts: web_servers
|
||||||
|
become: true
|
||||||
|
|
||||||
|
vars:
|
||||||
|
app_user: deploy
|
||||||
|
docker_compose_version: "2.24.0"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
# ==========================================
|
||||||
|
# 1. Verify Docker Installation
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Check if Docker is installed
|
||||||
|
command: docker --version
|
||||||
|
register: docker_check
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Display Docker version
|
||||||
|
debug:
|
||||||
|
msg: "Docker is already installed: {{ docker_check.stdout }}"
|
||||||
|
when: docker_check.rc == 0
|
||||||
|
|
||||||
|
- name: Install Docker if not present
|
||||||
|
block:
|
||||||
|
- name: Update apt cache
|
||||||
|
apt:
|
||||||
|
update_cache: yes
|
||||||
|
|
||||||
|
- name: Install prerequisites
|
||||||
|
apt:
|
||||||
|
name:
|
||||||
|
- apt-transport-https
|
||||||
|
- ca-certificates
|
||||||
|
- curl
|
||||||
|
- gnupg
|
||||||
|
- lsb-release
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Add Docker GPG key
|
||||||
|
apt_key:
|
||||||
|
url: https://download.docker.com/linux/ubuntu/gpg
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Add Docker repository
|
||||||
|
apt_repository:
|
||||||
|
repo: "deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Install Docker
|
||||||
|
apt:
|
||||||
|
name:
|
||||||
|
- docker-ce
|
||||||
|
- docker-ce-cli
|
||||||
|
- containerd.io
|
||||||
|
state: present
|
||||||
|
update_cache: yes
|
||||||
|
when: docker_check.rc != 0
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 2. Configure Docker
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Add deploy user to docker group
|
||||||
|
user:
|
||||||
|
name: "{{ app_user }}"
|
||||||
|
groups: docker
|
||||||
|
append: yes
|
||||||
|
|
||||||
|
- name: Ensure Docker service is enabled and started
|
||||||
|
systemd:
|
||||||
|
name: docker
|
||||||
|
enabled: yes
|
||||||
|
state: started
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 3. Install Docker Compose Plugin
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Check if Docker Compose plugin is installed
|
||||||
|
command: docker compose version
|
||||||
|
register: compose_check
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Display Docker Compose version
|
||||||
|
debug:
|
||||||
|
msg: "Docker Compose is already installed: {{ compose_check.stdout }}"
|
||||||
|
when: compose_check.rc == 0
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 4. Configure Docker Daemon
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Create Docker daemon configuration
|
||||||
|
copy:
|
||||||
|
dest: /etc/docker/daemon.json
|
||||||
|
content: |
|
||||||
|
{
|
||||||
|
"log-driver": "json-file",
|
||||||
|
"log-opts": {
|
||||||
|
"max-size": "10m",
|
||||||
|
"max-file": "3"
|
||||||
|
},
|
||||||
|
"live-restore": true
|
||||||
|
}
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: '0644'
|
||||||
|
notify: Restart Docker
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 5. Firewall Configuration
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Allow HTTP traffic
|
||||||
|
ufw:
|
||||||
|
rule: allow
|
||||||
|
port: '80'
|
||||||
|
proto: tcp
|
||||||
|
|
||||||
|
- name: Allow HTTPS traffic
|
||||||
|
ufw:
|
||||||
|
rule: allow
|
||||||
|
port: '443'
|
||||||
|
proto: tcp
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 6. Verification
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
- name: Get Docker info
|
||||||
|
command: docker info
|
||||||
|
register: docker_info
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Get Docker Compose version
|
||||||
|
command: docker compose version
|
||||||
|
register: compose_version
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Display setup summary
|
||||||
|
debug:
|
||||||
|
msg:
|
||||||
|
- "=========================================="
|
||||||
|
- "Docker Setup Complete"
|
||||||
|
- "=========================================="
|
||||||
|
- "Docker Version: {{ docker_check.stdout }}"
|
||||||
|
- "Docker Compose: {{ compose_version.stdout }}"
|
||||||
|
- "User '{{ app_user }}' added to docker group"
|
||||||
|
- "Firewall: HTTP (80) and HTTPS (443) allowed"
|
||||||
|
- "=========================================="
|
||||||
|
- ""
|
||||||
|
- "Next Steps:"
|
||||||
|
- "1. Log out and back in for docker group to take effect"
|
||||||
|
- "2. Run deployment playbook to start containers"
|
||||||
|
|
||||||
|
handlers:
|
||||||
|
- name: Restart Docker
|
||||||
|
systemd:
|
||||||
|
name: docker
|
||||||
|
state: restarted
|
||||||
@@ -65,6 +65,10 @@ services:
|
|||||||
# Production restart policy
|
# Production restart policy
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
|
# Override user setting - container must start as root for gosu to work
|
||||||
|
# The entrypoint script will use gosu to switch to appuser after setup
|
||||||
|
user: "root"
|
||||||
|
|
||||||
# Override build args for production
|
# Override build args for production
|
||||||
build:
|
build:
|
||||||
args:
|
args:
|
||||||
@@ -81,7 +85,7 @@ services:
|
|||||||
|
|
||||||
# Stricter health checks
|
# Stricter health checks
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "php-fpm-healthcheck"]
|
test: ["CMD", "php", "-v"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -108,12 +112,11 @@ services:
|
|||||||
|
|
||||||
# Remove development volumes
|
# Remove development volumes
|
||||||
volumes:
|
volumes:
|
||||||
# Keep only necessary volumes
|
# Mount entire storage directory as single volume to avoid subdirectory mount issues
|
||||||
- storage-logs:/var/www/html/storage/logs:rw
|
# on read-only overlay filesystem
|
||||||
- storage-cache:/var/www/html/storage/cache:rw
|
- storage:/var/www/html/storage:rw
|
||||||
- storage-queue:/var/www/html/storage/queue:rw
|
# Mount .env file from shared directory (production environment variables)
|
||||||
- storage-discovery:/var/www/html/storage/discovery:rw
|
- /home/deploy/michaelschiemer/shared/.env.production:/var/www/html/.env:ro
|
||||||
- storage-uploads:/var/www/html/storage/uploads:rw
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
# Production restart policy
|
# Production restart policy
|
||||||
@@ -184,9 +187,30 @@ services:
|
|||||||
labels: "service,environment"
|
labels: "service,environment"
|
||||||
|
|
||||||
queue-worker:
|
queue-worker:
|
||||||
|
# Use same image as php service (has application code copied)
|
||||||
|
image: framework-production-php
|
||||||
|
|
||||||
# Production restart policy
|
# Production restart policy
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
|
# Override user setting - container must start as root for gosu to work
|
||||||
|
# The entrypoint script will use gosu to switch to appuser after setup
|
||||||
|
user: "root"
|
||||||
|
|
||||||
|
# Override entrypoint - use php image's entrypoint for proper setup
|
||||||
|
entrypoint: ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
|
||||||
|
# Worker command - executed after entrypoint setup
|
||||||
|
command: ["php", "/var/www/html/worker.php"]
|
||||||
|
|
||||||
|
# Remove development volumes
|
||||||
|
volumes:
|
||||||
|
# Mount entire storage directory as single volume to avoid subdirectory mount issues
|
||||||
|
# on read-only overlay filesystem
|
||||||
|
- storage:/var/www/html/storage:rw
|
||||||
|
# Mount .env file from shared directory (production environment variables)
|
||||||
|
- /home/deploy/michaelschiemer/shared/.env.production:/var/www/html/.env:ro
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- APP_ENV=production
|
- APP_ENV=production
|
||||||
- WORKER_DEBUG=false
|
- WORKER_DEBUG=false
|
||||||
@@ -202,8 +226,8 @@ services:
|
|||||||
reservations:
|
reservations:
|
||||||
memory: 1G
|
memory: 1G
|
||||||
cpus: '1.0'
|
cpus: '1.0'
|
||||||
# Scale queue workers in production
|
# Note: replicas removed due to conflict with container_name
|
||||||
replicas: 2
|
# To scale queue workers, use separate docker-compose service definitions
|
||||||
|
|
||||||
# JSON logging
|
# JSON logging
|
||||||
logging:
|
logging:
|
||||||
@@ -265,16 +289,8 @@ volumes:
|
|||||||
certbot-logs:
|
certbot-logs:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
# Application storage volumes
|
# Application storage volume (single volume for entire storage directory)
|
||||||
storage-logs:
|
storage:
|
||||||
driver: local
|
|
||||||
storage-cache:
|
|
||||||
driver: local
|
|
||||||
storage-queue:
|
|
||||||
driver: local
|
|
||||||
storage-discovery:
|
|
||||||
driver: local
|
|
||||||
storage-uploads:
|
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
# Database volume with backup driver (optional)
|
# Database volume with backup driver (optional)
|
||||||
|
|||||||
3
docker/php/.dockerignore
Normal file
3
docker/php/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Exclude storage directory to allow Docker volume mounts
|
||||||
|
# Docker needs to create these directories fresh during volume mounting
|
||||||
|
storage/
|
||||||
@@ -69,6 +69,9 @@ RUN composer install --no-scripts --no-autoloader --ignore-platform-reqs || \
|
|||||||
COPY docker/php/php.common.ini /usr/local/etc/php/php.common.ini
|
COPY docker/php/php.common.ini /usr/local/etc/php/php.common.ini
|
||||||
COPY docker/php/php.${ENV}.ini /usr/local/etc/php/php.ini
|
COPY docker/php/php.${ENV}.ini /usr/local/etc/php/php.ini
|
||||||
|
|
||||||
|
# Kopiere PHP-FPM Pool-Konfiguration
|
||||||
|
COPY docker/php/zz-docker.conf /usr/local/etc/php-fpm.d/zz-docker.conf
|
||||||
|
|
||||||
# Xdebug-Konfiguration nur wenn dev
|
# Xdebug-Konfiguration nur wenn dev
|
||||||
RUN if [ "$ENV" = "dev" ] && [ -f docker/php/xdebug.ini ]; then \
|
RUN if [ "$ENV" = "dev" ] && [ -f docker/php/xdebug.ini ]; then \
|
||||||
cp docker/php/xdebug.ini /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini; \
|
cp docker/php/xdebug.ini /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini; \
|
||||||
@@ -84,22 +87,22 @@ RUN composer dump-autoload --optimize
|
|||||||
COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
RUN mkdir -p /var/www/html/cache \
|
# Remove entire storage directory tree copied from COPY . .
|
||||||
/var/www/html/storage \
|
# But we MUST create the empty parent directory so Docker can mount subdirectories
|
||||||
/var/www/html/storage/logs \
|
RUN rm -rf /var/www/html/storage && mkdir -p /var/www/html/storage
|
||||||
/var/www/html/storage/cache \
|
|
||||||
/var/www/html/storage/analytics \
|
|
||||||
/var/www/html/var \
|
|
||||||
/var/www/html/var/cache \
|
|
||||||
/var/www/html/var/logs
|
|
||||||
|
|
||||||
# Erstelle uploads-Verzeichnis
|
# CRITICAL: The storage directory must exist as an empty directory in the image
|
||||||
RUN mkdir -p /var/www/html/storage/uploads
|
# This allows Docker to mount Named Volumes to subdirectories (storage/cache, storage/logs, etc.)
|
||||||
|
# without needing to create the parent directory at runtime (which fails due to read-only overlay)
|
||||||
|
|
||||||
# Danach erst den Nutzer wechseln!
|
# Create appuser but DON'T switch yet - let entrypoint handle volumes first
|
||||||
RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser -m appuser
|
RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser -m appuser
|
||||||
RUN chown -R appuser:appuser /var/www/html
|
RUN chown -R appuser:appuser /var/www/html
|
||||||
USER appuser
|
|
||||||
|
# Install gosu for secure user switching in entrypoint (Debian alternative to su-exec)
|
||||||
|
RUN apt-get update && apt-get install -y gosu && apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Note: USER switch happens in entrypoint AFTER volumes are mounted
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
CMD ["php-fpm"]
|
CMD ["php-fpm"]
|
||||||
|
|||||||
@@ -1,20 +1,48 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
# Ensure storage directories exist and have correct permissions
|
# This script runs as root to handle Docker volume mounting,
|
||||||
mkdir -p /var/www/html/storage/analytics \
|
# then switches to appuser for security
|
||||||
/var/www/html/storage/logs \
|
|
||||||
/var/www/html/storage/cache \
|
# CRITICAL: Do NOT create ANY subdirectories under /var/www/html/storage!
|
||||||
/var/www/html/var/cache \
|
# Docker needs to create the storage directory tree when mounting Named Volumes.
|
||||||
|
# Creating storage or any storage/* subdirectory here prevents Docker volume mounting.
|
||||||
|
|
||||||
|
# Only create directories that are NOT under storage/ and are NOT volume mount points
|
||||||
|
mkdir -p /var/www/html/var/cache \
|
||||||
/var/www/html/var/logs \
|
/var/www/html/var/logs \
|
||||||
/var/www/html/cache
|
/var/www/html/cache
|
||||||
|
|
||||||
# Set correct ownership and permissions for appuser
|
# Set correct ownership and permissions for appuser
|
||||||
chown -R appuser:appuser /var/www/html/storage \
|
# Volume mount points are created by Docker and will be owned by root initially
|
||||||
/var/www/html/var \
|
# We fix ownership AFTER Docker has mounted them
|
||||||
/var/www/html/cache
|
|
||||||
|
|
||||||
chmod -R 775 /var/www/html/storage \
|
# Wait for Docker to finish mounting volumes
|
||||||
/var/www/html/var \
|
sleep 1
|
||||||
/var/www/html/cache
|
|
||||||
|
|
||||||
|
# NOW we can safely create non-volume storage subdirectories
|
||||||
|
# Docker has already mounted: storage/logs, storage/cache, storage/queue, storage/discovery, storage/uploads
|
||||||
|
# We create other directories that are NOT volume mounts:
|
||||||
|
mkdir -p /var/www/html/storage/analytics 2>/dev/null || true
|
||||||
|
mkdir -p /var/www/html/storage/sessions 2>/dev/null || true
|
||||||
|
|
||||||
|
# Fix ownership for all storage directories (including mounted volumes)
|
||||||
|
if [ -d /var/www/html/storage ]; then
|
||||||
|
chown -R appuser:appuser /var/www/html/storage 2>/dev/null || true
|
||||||
|
chmod -R 775 /var/www/html/storage 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
chown -R appuser:appuser /var/www/html/var 2>/dev/null || true
|
||||||
|
chown -R appuser:appuser /var/www/html/cache 2>/dev/null || true
|
||||||
|
|
||||||
|
chmod -R 775 /var/www/html/var 2>/dev/null || true
|
||||||
|
chmod -R 775 /var/www/html/cache 2>/dev/null || true
|
||||||
|
|
||||||
|
# For PHP-FPM, run as root and let it manage user switching internally
|
||||||
|
# PHP-FPM will drop privileges to the user specified in pool configuration
|
||||||
|
# For other commands (console.php, etc.), switch to appuser
|
||||||
|
if [ "$1" = "php-fpm" ]; then
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
else
|
||||||
|
exec gosu appuser "$@"
|
||||||
|
fi
|
||||||
|
|||||||
22
docker/php/zz-docker.conf
Normal file
22
docker/php/zz-docker.conf
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[global]
|
||||||
|
daemonize = no
|
||||||
|
error_log = /proc/self/fd/2
|
||||||
|
|
||||||
|
[www]
|
||||||
|
; Unix user/group of processes
|
||||||
|
user = appuser
|
||||||
|
group = appuser
|
||||||
|
|
||||||
|
; The address on which to accept FastCGI requests.
|
||||||
|
listen = 9000
|
||||||
|
|
||||||
|
; Clear environment in FPM workers
|
||||||
|
clear_env = no
|
||||||
|
|
||||||
|
; Catch output from PHP workers
|
||||||
|
catch_workers_output = yes
|
||||||
|
|
||||||
|
; Redirect worker stdout and stderr into main error log
|
||||||
|
access.log = /proc/self/fd/2
|
||||||
|
php_admin_value[error_log] = /proc/self/fd/2
|
||||||
|
php_admin_flag[log_errors] = on
|
||||||
@@ -300,30 +300,58 @@ final readonly class UserCommands
|
|||||||
```html
|
```html
|
||||||
<!-- ✅ Framework Template Patterns -->
|
<!-- ✅ Framework Template Patterns -->
|
||||||
<div class="user-card">
|
<div class="user-card">
|
||||||
<h2>{user.name}</h2>
|
<!-- Object property access -->
|
||||||
<p>{user.email}</p>
|
<h2>{{ $user->name }}</h2>
|
||||||
|
<p>{{ $user->email }}</p>
|
||||||
|
|
||||||
<!-- Conditional Rendering -->
|
<!-- Method calls -->
|
||||||
<if condition="user.isAdmin">
|
<p>{{ $user->getFullName() }}</p>
|
||||||
<span class="badge">Admin</span>
|
|
||||||
</if>
|
|
||||||
|
|
||||||
<!-- Loop Rendering -->
|
<!-- Conditional Rendering - if attribute -->
|
||||||
<for items="user.posts" as="post">
|
<span class="badge" if="{{ $user->isAdmin() }}">Admin</span>
|
||||||
<article>
|
|
||||||
<h3>{post.title}</h3>
|
<!-- Negation -->
|
||||||
<p>{post.excerpt}</p>
|
<p if="!{{ $user->isAdmin() }}">Regular User</p>
|
||||||
</for>
|
|
||||||
</for>
|
<!-- Loop Rendering - foreach attribute (PHP-style) -->
|
||||||
|
<article foreach="$user->posts as $post">
|
||||||
|
<h3>{{ $post->title }}</h3>
|
||||||
|
<p>{{ $post->getExcerpt() }}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Loop with key-value pairs -->
|
||||||
|
<div foreach="$items as $key => $value">
|
||||||
|
<span>{{ $key }}: {{ $value }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Component Inclusion -->
|
<!-- Component Inclusion -->
|
||||||
<include template="components/avatar" data="user.avatar" />
|
<include template="components/avatar" data="{{ $user->avatar }}" />
|
||||||
|
|
||||||
<!-- Slot System -->
|
<!-- Slot System -->
|
||||||
<slot name="header">Default Header</slot>
|
<slot name="header">Default Header</slot>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**CRITICAL TEMPLATE ENGINE RULES**:
|
||||||
|
1. **Placeholder Syntax**: ALWAYS `{{ $variable }}` with dollar sign
|
||||||
|
2. **Object Access**:
|
||||||
|
- Properties: `{{ $object->property }}`
|
||||||
|
- Methods: `{{ $object->method() }}`
|
||||||
|
- Arrays: `{{ $array['key'] }}` (still supported)
|
||||||
|
3. **Conditional Rendering**: Use `if` attribute
|
||||||
|
- Example: `<div if="{{ $hasData }}">content</div>`
|
||||||
|
- Negation: `<div if="!{{ $hasData }}">no data</div>`
|
||||||
|
4. **Loop Rendering**: Use `foreach` attribute (PHP-style)
|
||||||
|
- Simple: `<div foreach="$items as $item">{{ $item->name }}</div>`
|
||||||
|
- With key: `<tr foreach="$models as $index => $model">...</tr>`
|
||||||
|
5. **NO custom tags for logic**: Only standard HTML tags with attributes
|
||||||
|
|
||||||
|
**PHP-Style Syntax Benefits**:
|
||||||
|
- Native PHP developers immediately understand the syntax
|
||||||
|
- Object properties and methods work naturally
|
||||||
|
- `foreach` syntax identical to PHP
|
||||||
|
- Supports key-value iteration out of the box
|
||||||
|
|
||||||
**Template Processors Integration**:
|
**Template Processors Integration**:
|
||||||
```php
|
```php
|
||||||
// ✅ Custom Template Processor Pattern
|
// ✅ Custom Template Processor Pattern
|
||||||
@@ -348,6 +376,17 @@ final readonly class DesignSystemProcessor
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Registered Template Processors**:
|
||||||
|
- **PlaceholderReplacer**: Variable substitution with `{{ $var }}` syntax, object access `{{ $obj->prop }}`, method calls `{{ $obj->method() }}`
|
||||||
|
- **ForeachAttributeProcessor**: Loop rendering via `foreach="$items as $item"` attribute
|
||||||
|
- **IfAttributeProcessor**: Conditional rendering via `if="{{ $condition }}"` attribute
|
||||||
|
- **ComponentProcessor**: Component inclusion & slot system
|
||||||
|
- **LayoutTagProcessor**: Layout system integration
|
||||||
|
- **MetaManipulator**: Meta tags & SEO management
|
||||||
|
- **AssetInjector**: CSS/JS asset management
|
||||||
|
- **CsrfTokenProcessor**: Security integration
|
||||||
|
- **HoneypotProcessor**: Spam protection
|
||||||
|
|
||||||
**CSS Architecture (ITCSS) Expertise**:
|
**CSS Architecture (ITCSS) Expertise**:
|
||||||
|
|
||||||
**Layer Structure**:
|
**Layer Structure**:
|
||||||
@@ -514,16 +553,14 @@ enum SpacingSize: string
|
|||||||
<!-- ✅ WCAG-compliant Templates -->
|
<!-- ✅ WCAG-compliant Templates -->
|
||||||
<nav aria-label="Main navigation">
|
<nav aria-label="Main navigation">
|
||||||
<ul role="list">
|
<ul role="list">
|
||||||
<for items="menuItems" as="item">
|
<li foreach="$menuItems as $item">
|
||||||
<li>
|
|
||||||
<a
|
<a
|
||||||
href="{item.url}"
|
href="{{ $item['url'] }}"
|
||||||
aria-current="{item.isActive ? 'page' : null}"
|
aria-current="{{ $item['isActive'] ? 'page' : null }}"
|
||||||
>
|
>
|
||||||
{item.label}
|
{{ $item['label'] }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</for>
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -540,16 +577,14 @@ enum SpacingSize: string
|
|||||||
aria-labelledby="email-label"
|
aria-labelledby="email-label"
|
||||||
aria-describedby="email-hint email-error"
|
aria-describedby="email-hint email-error"
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
aria-invalid="{hasError ? 'true' : 'false'}"
|
aria-invalid="{{ $hasError ? 'true' : 'false' }}"
|
||||||
/>
|
/>
|
||||||
<span id="email-hint" class="form-hint">
|
<span id="email-hint" class="form-hint">
|
||||||
We'll never share your email
|
We'll never share your email
|
||||||
</span>
|
</span>
|
||||||
<if condition="hasError">
|
<span id="email-error" role="alert" class="form-error" if="{{ $hasError }}">
|
||||||
<span id="email-error" role="alert" class="form-error">
|
{{ $errorMessage }}
|
||||||
{errorMessage}
|
|
||||||
</span>
|
</span>
|
||||||
</if>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
```
|
```
|
||||||
@@ -629,16 +664,21 @@ final readonly class DesignSystemRegistry
|
|||||||
- **Design Token Coverage**: 100% - keine Hard-coded Colors/Spacing
|
- **Design Token Coverage**: 100% - keine Hard-coded Colors/Spacing
|
||||||
|
|
||||||
**Integration mit Template Processors**:
|
**Integration mit Template Processors**:
|
||||||
- **PlaceholderReplacer**: Variable Substitution
|
- **PlaceholderReplacer**: Variable Substitution mit `{{ $var }}` Syntax
|
||||||
- **ComponentProcessor**: Component Inclusion & Slot System
|
- **ComponentProcessor**: Component Inclusion & Slot System
|
||||||
- **ForProcessor**: Loop Rendering
|
- **ForAttributeProcessor**: Loop Rendering via `for-items` und `for-value` Attribute
|
||||||
- **IfProcessor**: Conditional Rendering
|
- **IfAttributeProcessor**: Conditional Rendering via `if` Attribut (+ `condition` deprecated fallback)
|
||||||
- **LayoutTagProcessor**: Layout System
|
- **LayoutTagProcessor**: Layout System
|
||||||
- **MetaManipulator**: Meta Tags & SEO
|
- **MetaManipulator**: Meta Tags & SEO
|
||||||
- **AssetInjector**: CSS/JS Asset Management
|
- **AssetInjector**: CSS/JS Asset Management
|
||||||
- **CsrfTokenProcessor**: Security Integration
|
- **CsrfTokenProcessor**: Security Integration
|
||||||
- **HoneypotProcessor**: Spam Protection
|
- **HoneypotProcessor**: Spam Protection
|
||||||
|
|
||||||
|
**Deprecated Syntax (backwards compatible)**:
|
||||||
|
- ❌ `<for items="..." as="...">` → ✅ Use `for-items` and `for-value` attributes
|
||||||
|
- ❌ `<if condition="...">` → ✅ Use `if` attribute on element
|
||||||
|
- ❌ `condition` attribute → ✅ Use `if` attribute (condition still supported)
|
||||||
|
|
||||||
**Performance Optimization**:
|
**Performance Optimization**:
|
||||||
```php
|
```php
|
||||||
// ✅ Critical CSS Extraction
|
// ✅ Critical CSS Extraction
|
||||||
|
|||||||
438
docs/whatsapp-notification-channel.md
Normal file
438
docs/whatsapp-notification-channel.md
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
# WhatsApp Notification Channel
|
||||||
|
|
||||||
|
Dokumentation für den WhatsApp Business API Notification Channel im Custom PHP Framework.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Der WhatsApp Channel ermöglicht das Versenden von Notifications über die WhatsApp Business API. Es werden sowohl Textnachrichten als auch Template-basierte Nachrichten unterstützt.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
✅ **Text Messages**: Einfache Textnachrichten mit Markdown-Formatierung
|
||||||
|
✅ **Template Messages**: WhatsApp-approved Message Templates mit Parametern
|
||||||
|
✅ **Action Buttons**: Support für Action URLs und Labels
|
||||||
|
✅ **Type Safety**: Framework-konforme Value Objects für alle Identifier
|
||||||
|
✅ **HttpClient Integration**: Nutzung des Framework's HttpClient Moduls
|
||||||
|
✅ **Error Handling**: Umfassende Exception-Behandlung mit WhatsAppApiException
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
WhatsAppChannel (NotificationChannelInterface)
|
||||||
|
↓
|
||||||
|
WhatsAppClient (API Communication)
|
||||||
|
↓
|
||||||
|
HttpClient (Framework's HTTP Module)
|
||||||
|
↓
|
||||||
|
WhatsApp Business API
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation & Setup
|
||||||
|
|
||||||
|
### 1. WhatsApp Business Account einrichten
|
||||||
|
|
||||||
|
1. Erstelle einen WhatsApp Business Account bei Facebook
|
||||||
|
2. Registriere deine Business Phone Number
|
||||||
|
3. Generiere einen Access Token
|
||||||
|
4. Notiere deine Phone Number ID und Business Account ID
|
||||||
|
|
||||||
|
**URLs**:
|
||||||
|
- WhatsApp Business Dashboard: https://business.facebook.com/settings/whatsapp-business-accounts
|
||||||
|
- Meta for Developers: https://developers.facebook.com/
|
||||||
|
|
||||||
|
### 2. Konfiguration
|
||||||
|
|
||||||
|
Die Konfiguration erfolgt aktuell hardcoded in `WhatsAppConfig::createDefault()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Framework\Notification\Channels\WhatsApp\WhatsAppConfig;
|
||||||
|
|
||||||
|
$config = WhatsAppConfig::createDefault();
|
||||||
|
// Oder manuell:
|
||||||
|
$config = new WhatsAppConfig(
|
||||||
|
accessToken: 'YOUR_ACCESS_TOKEN',
|
||||||
|
phoneNumberId: 'YOUR_PHONE_NUMBER_ID',
|
||||||
|
businessAccountId: WhatsAppBusinessAccountId::fromString('YOUR_BUSINESS_ACCOUNT_ID'),
|
||||||
|
apiVersion: 'v18.0'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
### Basic Text Message
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Framework\Core\ValueObjects\PhoneNumber;
|
||||||
|
use App\Framework\Notification\Notification;
|
||||||
|
use App\Framework\Notification\ValueObjects\NotificationChannel;
|
||||||
|
use App\Framework\Notification\ValueObjects\SystemNotificationType;
|
||||||
|
|
||||||
|
// Create notification
|
||||||
|
$notification = Notification::create(
|
||||||
|
recipientId: 'user_123',
|
||||||
|
type: SystemNotificationType::SYSTEM_ALERT(),
|
||||||
|
title: 'Important Update',
|
||||||
|
body: 'Your order has been shipped!',
|
||||||
|
NotificationChannel::WHATSAPP
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send via dispatcher
|
||||||
|
$result = $notificationDispatcher->send($notification);
|
||||||
|
|
||||||
|
if ($result->isSuccessful()) {
|
||||||
|
echo "WhatsApp message sent: {$result->getMetadata()['message_id']}";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template Message
|
||||||
|
|
||||||
|
WhatsApp erfordert pre-approved Templates für Marketing und Notifications.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$notification = Notification::create(
|
||||||
|
recipientId: 'user_123',
|
||||||
|
type: SystemNotificationType::ORDER_CONFIRMATION(),
|
||||||
|
title: 'Order Confirmation',
|
||||||
|
body: 'Template will be used',
|
||||||
|
NotificationChannel::WHATSAPP
|
||||||
|
)->withData([
|
||||||
|
'whatsapp_template_id' => 'order_confirmation',
|
||||||
|
'whatsapp_language' => 'en_US',
|
||||||
|
'whatsapp_template_params' => [
|
||||||
|
'John Doe', // Customer name
|
||||||
|
'ORD-12345', // Order number
|
||||||
|
'€99.99' // Total amount
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $notificationDispatcher->send($notification);
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Action Button
|
||||||
|
|
||||||
|
```php
|
||||||
|
$notification = Notification::create(
|
||||||
|
recipientId: 'user_123',
|
||||||
|
type: SystemNotificationType::PAYMENT_REMINDER(),
|
||||||
|
title: 'Payment Due',
|
||||||
|
body: 'Your invoice is ready for payment.',
|
||||||
|
NotificationChannel::WHATSAPP
|
||||||
|
)->withAction(
|
||||||
|
url: 'https://example.com/invoices/123',
|
||||||
|
label: 'View Invoice'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Message will include: "👉 View Invoice: https://example.com/invoices/123"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phone Number Resolver
|
||||||
|
|
||||||
|
Implementiere `UserPhoneNumberResolver` für deine Anwendung:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Framework\Core\ValueObjects\PhoneNumber;
|
||||||
|
use App\Framework\Notification\Channels\WhatsApp\UserPhoneNumberResolver;
|
||||||
|
|
||||||
|
final readonly class DatabaseUserPhoneNumberResolver implements UserPhoneNumberResolver
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private UserRepository $userRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function resolvePhoneNumber(string $userId): ?PhoneNumber
|
||||||
|
{
|
||||||
|
$user = $this->userRepository->find($userId);
|
||||||
|
|
||||||
|
if ($user === null || $user->phoneNumber === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return PhoneNumber::fromString($user->phoneNumber);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
// Invalid phone number format
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Value Objects
|
||||||
|
|
||||||
|
### PhoneNumber
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Framework\Core\ValueObjects\PhoneNumber;
|
||||||
|
|
||||||
|
// E.164 format required: +[country code][number]
|
||||||
|
$phone = PhoneNumber::fromString('+4917612345678');
|
||||||
|
|
||||||
|
// Or from parts
|
||||||
|
$phone = PhoneNumber::fromInternational('49', '17612345678');
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
$phone->toString(); // +4917612345678
|
||||||
|
$phone->toDisplayFormat(); // +49 176 126 456 78
|
||||||
|
$phone->getCountryCode(); // 49
|
||||||
|
$phone->getSubscriberNumber(); // 17612345678
|
||||||
|
```
|
||||||
|
|
||||||
|
### WhatsAppTemplateId
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Framework\Notification\Channels\WhatsApp\ValueObjects\WhatsAppTemplateId;
|
||||||
|
|
||||||
|
// Template names must be lowercase alphanumeric with underscores
|
||||||
|
$templateId = WhatsAppTemplateId::fromString('order_confirmation');
|
||||||
|
$templateId = WhatsAppTemplateId::fromString('hello_world');
|
||||||
|
|
||||||
|
// ❌ Invalid
|
||||||
|
$templateId = WhatsAppTemplateId::fromString('OrderConfirmation'); // Uppercase not allowed
|
||||||
|
$templateId = WhatsAppTemplateId::fromString('order-confirmation'); // Hyphen not allowed
|
||||||
|
```
|
||||||
|
|
||||||
|
### WhatsAppBusinessAccountId
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Framework\Notification\Channels\WhatsApp\ValueObjects\WhatsAppBusinessAccountId;
|
||||||
|
|
||||||
|
$accountId = WhatsAppBusinessAccountId::fromString('123456789012345');
|
||||||
|
```
|
||||||
|
|
||||||
|
### WhatsAppMessageId
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Framework\Notification\Channels\WhatsApp\ValueObjects\WhatsAppMessageId;
|
||||||
|
|
||||||
|
// Returned from API after sending
|
||||||
|
$messageId = WhatsAppMessageId::fromString('wamid.HBgNNDkxNzYxMjM0NTY3OBUCABIYFjNFQjBDNzE4RjAzMEE1NzQxODZEMDIA');
|
||||||
|
```
|
||||||
|
|
||||||
|
## WhatsApp Templates
|
||||||
|
|
||||||
|
### Template Erstellen
|
||||||
|
|
||||||
|
1. Gehe zu WhatsApp Business Manager
|
||||||
|
2. Navigiere zu "Message Templates"
|
||||||
|
3. Erstelle ein neues Template
|
||||||
|
4. Warte auf Approval (kann 24-48h dauern)
|
||||||
|
|
||||||
|
### Template Beispiel
|
||||||
|
|
||||||
|
**Template Name**: `order_confirmation`
|
||||||
|
**Language**: English (US)
|
||||||
|
**Category**: Transactional
|
||||||
|
**Body**:
|
||||||
|
```
|
||||||
|
Hello {{1}},
|
||||||
|
|
||||||
|
Your order {{2}} has been confirmed!
|
||||||
|
Total amount: {{3}}
|
||||||
|
|
||||||
|
Thank you for your purchase.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```php
|
||||||
|
$notification->withData([
|
||||||
|
'whatsapp_template_id' => 'order_confirmation',
|
||||||
|
'whatsapp_language' => 'en_US',
|
||||||
|
'whatsapp_template_params' => [
|
||||||
|
'John Doe', // {{1}}
|
||||||
|
'ORD-12345', // {{2}}
|
||||||
|
'€99.99' // {{3}}
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Test Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec php php tests/debug/test-whatsapp-notification.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig**: Ersetze die Test-Telefonnummer in der Datei mit deiner eigenen WhatsApp-Nummer!
|
||||||
|
|
||||||
|
### Unit Test
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Framework\Notification\Channels\WhatsAppChannel;
|
||||||
|
use App\Framework\Notification\Notification;
|
||||||
|
|
||||||
|
it('sends WhatsApp notification successfully', function () {
|
||||||
|
$mockClient = Mockery::mock(WhatsAppClient::class);
|
||||||
|
$mockResolver = Mockery::mock(UserPhoneNumberResolver::class);
|
||||||
|
|
||||||
|
$mockResolver->shouldReceive('resolvePhoneNumber')
|
||||||
|
->with('user_123')
|
||||||
|
->andReturn(PhoneNumber::fromString('+4917612345678'));
|
||||||
|
|
||||||
|
$mockClient->shouldReceive('sendTextMessage')
|
||||||
|
->once()
|
||||||
|
->andReturn(new WhatsAppResponse(
|
||||||
|
success: true,
|
||||||
|
messageId: WhatsAppMessageId::fromString('wamid_test_123')
|
||||||
|
));
|
||||||
|
|
||||||
|
$channel = new WhatsAppChannel($mockClient, $mockResolver);
|
||||||
|
|
||||||
|
$notification = Notification::create(
|
||||||
|
recipientId: 'user_123',
|
||||||
|
type: SystemNotificationType::SYSTEM_ALERT(),
|
||||||
|
title: 'Test',
|
||||||
|
body: 'Test message',
|
||||||
|
NotificationChannel::WHATSAPP
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $channel->send($notification);
|
||||||
|
|
||||||
|
expect($result->isSuccessful())->toBeTrue();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### WhatsAppApiException
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Framework\Notification\Channels\WhatsApp\WhatsAppApiException;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $whatsappClient->sendTextMessage($phoneNumber, $message);
|
||||||
|
} catch (WhatsAppApiException $e) {
|
||||||
|
// API returned an error
|
||||||
|
$httpCode = $e->getHttpStatusCode();
|
||||||
|
$message = $e->getMessage();
|
||||||
|
|
||||||
|
// Log or handle error
|
||||||
|
$logger->error('WhatsApp API error', [
|
||||||
|
'http_code' => $httpCode,
|
||||||
|
'message' => $message
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Errors
|
||||||
|
|
||||||
|
| Error Code | Beschreibung | Lösung |
|
||||||
|
|------------|--------------|--------|
|
||||||
|
| 100 | Invalid parameter | Prüfe Parameter (phone number format, template ID) |
|
||||||
|
| 131009 | Parameter value not valid | Template parameters stimmen nicht mit Template überein |
|
||||||
|
| 131026 | Message undeliverable | Empfänger hat WhatsApp nicht oder blockiert Business Account |
|
||||||
|
| 131047 | Re-engagement message | User muss zuerst Business Account kontaktieren |
|
||||||
|
| 190 | Access token expired | Generiere neuen Access Token |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Phone Number Validation
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Validate before using
|
||||||
|
try {
|
||||||
|
$phone = PhoneNumber::fromString($userInput);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
// Handle invalid phone number
|
||||||
|
return 'Invalid phone number format';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Don't assume format
|
||||||
|
$phone = PhoneNumber::fromString($_POST['phone']); // Can throw exception
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Template Usage
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Use templates for marketing/promotional content
|
||||||
|
$notification->withData([
|
||||||
|
'whatsapp_template_id' => 'weekly_newsletter',
|
||||||
|
'whatsapp_language' => 'en_US'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ✅ Use text messages for immediate transactional updates
|
||||||
|
$notification = Notification::create(
|
||||||
|
recipientId: 'user_123',
|
||||||
|
type: SystemNotificationType::SYSTEM_ALERT(),
|
||||||
|
title: 'Server Alert',
|
||||||
|
body: 'Critical: Database connection lost!',
|
||||||
|
NotificationChannel::WHATSAPP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Rate Limiting
|
||||||
|
|
||||||
|
WhatsApp hat Rate Limits pro Business Account:
|
||||||
|
- **Tier 1** (default): 1,000 unique contacts/24h
|
||||||
|
- **Tier 2**: 10,000 unique contacts/24h
|
||||||
|
- **Tier 3**: 100,000 unique contacts/24h
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Implement rate limiting
|
||||||
|
if ($this->rateLimiter->tooManyAttempts("whatsapp:{$userId}", 5, 3600)) {
|
||||||
|
throw new RateLimitException('Too many WhatsApp messages sent');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Opt-In Requirement
|
||||||
|
|
||||||
|
WhatsApp erfordert **explicit Opt-In** von Usern:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Check user consent before sending
|
||||||
|
if (!$user->hasWhatsAppOptIn()) {
|
||||||
|
return ChannelResult::failure(
|
||||||
|
channel: NotificationChannel::WHATSAPP,
|
||||||
|
errorMessage: 'User has not opted in to WhatsApp notifications'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Message nicht zugestellt
|
||||||
|
|
||||||
|
**Checklist**:
|
||||||
|
- [ ] Phone Number ist in E.164 Format (`+4917612345678`)
|
||||||
|
- [ ] Empfänger hat WhatsApp installiert
|
||||||
|
- [ ] Empfänger hat Business Account nicht blockiert
|
||||||
|
- [ ] Access Token ist gültig
|
||||||
|
- [ ] Template ist approved (für Template Messages)
|
||||||
|
- [ ] Rate Limits nicht überschritten
|
||||||
|
|
||||||
|
### Template Errors
|
||||||
|
|
||||||
|
**Problem**: "Parameter value not valid"
|
||||||
|
**Lösung**: Anzahl der Parameter muss exakt mit Template übereinstimmen
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Template hat 3 placeholders: {{1}}, {{2}}, {{3}}
|
||||||
|
// ✅ Correct
|
||||||
|
'whatsapp_template_params' => ['Param1', 'Param2', 'Param3']
|
||||||
|
|
||||||
|
// ❌ Wrong - zu wenige Parameter
|
||||||
|
'whatsapp_template_params' => ['Param1', 'Param2']
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access Token Expired
|
||||||
|
|
||||||
|
**Problem**: Error 190 - Access token has expired
|
||||||
|
**Lösung**: Generiere neuen Access Token im Facebook Business Manager
|
||||||
|
|
||||||
|
## Weiterführende Ressourcen
|
||||||
|
|
||||||
|
- **WhatsApp Business API Docs**: https://developers.facebook.com/docs/whatsapp/cloud-api
|
||||||
|
- **Message Templates**: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates
|
||||||
|
- **Error Codes**: https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes
|
||||||
|
- **E.164 Format**: https://en.wikipedia.org/wiki/E.164
|
||||||
|
|
||||||
|
## Framework Integration
|
||||||
|
|
||||||
|
Der WhatsApp Channel folgt allen Framework-Patterns:
|
||||||
|
|
||||||
|
✅ **Readonly Classes**: Alle VOs und Configs sind `final readonly`
|
||||||
|
✅ **Value Objects**: Keine Primitive Obsession (PhoneNumber, TemplateId, etc.)
|
||||||
|
✅ **No Inheritance**: Composition über Inheritance
|
||||||
|
✅ **Type Safety**: Strikte Typisierung für alle Parameter
|
||||||
|
✅ **Framework Compliance**: Integration mit HttpClient, Notification System
|
||||||
|
✅ **Explicit Dependencies**: Constructor Injection, keine Service Locators
|
||||||
219
examples/notification-multi-channel-example.php
Normal file
219
examples/notification-multi-channel-example.php
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Framework\Notification\Notification;
|
||||||
|
use App\Framework\Notification\NotificationDispatcher;
|
||||||
|
use App\Framework\Notification\Dispatcher\DispatchStrategy;
|
||||||
|
use App\Framework\Notification\ValueObjects\NotificationChannel;
|
||||||
|
use App\Framework\Notification\ValueObjects\NotificationPriority;
|
||||||
|
use App\Framework\Notification\ValueObjects\SystemNotificationType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-Channel Notification Dispatch Example
|
||||||
|
*
|
||||||
|
* Demonstrates the four dispatch strategies:
|
||||||
|
* - ALL: Send to all channels regardless of failures
|
||||||
|
* - FIRST_SUCCESS: Stop after first successful delivery
|
||||||
|
* - FALLBACK: Try next only if previous failed
|
||||||
|
* - ALL_OR_NONE: All must succeed or entire dispatch fails
|
||||||
|
*/
|
||||||
|
|
||||||
|
echo "=== Multi-Channel Notification Dispatch Examples ===\n\n";
|
||||||
|
|
||||||
|
// Setup (in real app, get from DI container)
|
||||||
|
$dispatcher = $container->get(NotificationDispatcher::class);
|
||||||
|
|
||||||
|
// Example 1: ALL Strategy - Send to all channels
|
||||||
|
echo "1. ALL Strategy - Send to all channels\n";
|
||||||
|
echo str_repeat("-", 50) . "\n";
|
||||||
|
|
||||||
|
$notification = Notification::create(
|
||||||
|
recipientId: 'user_123',
|
||||||
|
type: new SystemNotificationType('system.update'),
|
||||||
|
title: 'System Update Available',
|
||||||
|
body: 'A new system update is available. Please review and install.',
|
||||||
|
NotificationChannel::TELEGRAM,
|
||||||
|
NotificationChannel::EMAIL,
|
||||||
|
NotificationChannel::SMS
|
||||||
|
)->withPriority(NotificationPriority::NORMAL);
|
||||||
|
|
||||||
|
$result = $dispatcher->sendNow($notification, DispatchStrategy::ALL);
|
||||||
|
|
||||||
|
echo "Status: " . ($result->isSuccess() ? "✅ SUCCESS" : "❌ FAILURE") . "\n";
|
||||||
|
echo "Successful channels: " . count($result->getSuccessful()) . "\n";
|
||||||
|
echo "Failed channels: " . count($result->getFailed()) . "\n";
|
||||||
|
|
||||||
|
foreach ($result->getSuccessful() as $channelResult) {
|
||||||
|
echo " ✅ {$channelResult->channel->value}: " . json_encode($channelResult->metadata) . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($result->getFailed() as $channelResult) {
|
||||||
|
echo " ❌ {$channelResult->channel->value}: {$channelResult->errorMessage}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 2: FIRST_SUCCESS Strategy - Stop after first success
|
||||||
|
echo "2. FIRST_SUCCESS Strategy - Quick delivery\n";
|
||||||
|
echo str_repeat("-", 50) . "\n";
|
||||||
|
|
||||||
|
$notification = Notification::create(
|
||||||
|
recipientId: 'user_456',
|
||||||
|
type: new SystemNotificationType('order.shipped'),
|
||||||
|
title: 'Your Order Has Shipped',
|
||||||
|
body: 'Your order #12345 has been shipped and is on its way!',
|
||||||
|
NotificationChannel::TELEGRAM, // Try Telegram first
|
||||||
|
NotificationChannel::EMAIL, // Then Email if Telegram fails
|
||||||
|
NotificationChannel::SMS // Then SMS if Email fails
|
||||||
|
)->withPriority(NotificationPriority::HIGH);
|
||||||
|
|
||||||
|
$result = $dispatcher->sendNow($notification, DispatchStrategy::FIRST_SUCCESS);
|
||||||
|
|
||||||
|
echo "Status: " . ($result->isSuccess() ? "✅ SUCCESS" : "❌ FAILURE") . "\n";
|
||||||
|
echo "Delivery stopped after first success\n";
|
||||||
|
echo "Channels attempted: " . (count($result->getSuccessful()) + count($result->getFailed())) . "\n";
|
||||||
|
|
||||||
|
foreach ($result->getSuccessful() as $channelResult) {
|
||||||
|
echo " ✅ {$channelResult->channel->value}: Delivered successfully\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($result->getFailed() as $channelResult) {
|
||||||
|
echo " ❌ {$channelResult->channel->value}: {$channelResult->errorMessage}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 3: FALLBACK Strategy - Telegram -> Email -> SMS chain
|
||||||
|
echo "3. FALLBACK Strategy - Graceful degradation\n";
|
||||||
|
echo str_repeat("-", 50) . "\n";
|
||||||
|
|
||||||
|
$notification = Notification::create(
|
||||||
|
recipientId: 'user_789',
|
||||||
|
type: new SystemNotificationType('security.alert'),
|
||||||
|
title: 'Security Alert',
|
||||||
|
body: 'Unusual login activity detected on your account.',
|
||||||
|
NotificationChannel::TELEGRAM, // Primary: Telegram (instant)
|
||||||
|
NotificationChannel::EMAIL, // Fallback 1: Email (reliable)
|
||||||
|
NotificationChannel::SMS // Fallback 2: SMS (last resort)
|
||||||
|
)->withPriority(NotificationPriority::URGENT);
|
||||||
|
|
||||||
|
$result = $dispatcher->sendNow($notification, DispatchStrategy::FALLBACK);
|
||||||
|
|
||||||
|
echo "Status: " . ($result->isSuccess() ? "✅ SUCCESS" : "❌ FAILURE") . "\n";
|
||||||
|
echo "Fallback chain executed\n";
|
||||||
|
|
||||||
|
foreach ($result->getSuccessful() as $channelResult) {
|
||||||
|
echo " ✅ {$channelResult->channel->value}: Delivered (fallback stopped here)\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($result->getFailed() as $channelResult) {
|
||||||
|
echo " ❌ {$channelResult->channel->value}: Failed, tried next channel\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 4: ALL_OR_NONE Strategy - Critical notifications
|
||||||
|
echo "4. ALL_OR_NONE Strategy - All must succeed\n";
|
||||||
|
echo str_repeat("-", 50) . "\n";
|
||||||
|
|
||||||
|
$notification = Notification::create(
|
||||||
|
recipientId: 'user_101',
|
||||||
|
type: new SystemNotificationType('account.deleted'),
|
||||||
|
title: 'Account Deletion Confirmation',
|
||||||
|
body: 'Your account has been permanently deleted as requested.',
|
||||||
|
NotificationChannel::EMAIL,
|
||||||
|
NotificationChannel::SMS
|
||||||
|
)->withPriority(NotificationPriority::URGENT);
|
||||||
|
|
||||||
|
$result = $dispatcher->sendNow($notification, DispatchStrategy::ALL_OR_NONE);
|
||||||
|
|
||||||
|
echo "Status: " . ($result->isSuccess() ? "✅ ALL SUCCEEDED" : "❌ STOPPED ON FIRST FAILURE") . "\n";
|
||||||
|
echo "Successful channels: " . count($result->getSuccessful()) . "\n";
|
||||||
|
echo "Failed channels: " . count($result->getFailed()) . "\n";
|
||||||
|
|
||||||
|
if ($result->isFailure()) {
|
||||||
|
echo "⚠️ Critical notification failed - some channels did not receive the message\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($result->getSuccessful() as $channelResult) {
|
||||||
|
echo " ✅ {$channelResult->channel->value}: Delivered\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($result->getFailed() as $channelResult) {
|
||||||
|
echo " ❌ {$channelResult->channel->value}: {$channelResult->errorMessage}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 5: Async Multi-Channel with Strategy
|
||||||
|
echo "5. Async Multi-Channel Dispatch\n";
|
||||||
|
echo str_repeat("-", 50) . "\n";
|
||||||
|
|
||||||
|
$notification = Notification::create(
|
||||||
|
recipientId: 'user_202',
|
||||||
|
type: new SystemNotificationType('newsletter.weekly'),
|
||||||
|
title: 'Your Weekly Newsletter',
|
||||||
|
body: 'Check out this week\'s highlights and updates.',
|
||||||
|
NotificationChannel::EMAIL,
|
||||||
|
NotificationChannel::TELEGRAM
|
||||||
|
)->withPriority(NotificationPriority::LOW);
|
||||||
|
|
||||||
|
// Async dispatch - queued with priority mapping
|
||||||
|
$dispatcher->send($notification, async: true, strategy: DispatchStrategy::ALL);
|
||||||
|
|
||||||
|
echo "✅ Notification queued for async dispatch\n";
|
||||||
|
echo "Strategy: ALL (will attempt all channels in background)\n";
|
||||||
|
echo "Priority: LOW (mapped to queue priority)\n";
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 6: Strategy Selection Based on Priority
|
||||||
|
echo "6. Dynamic Strategy Selection\n";
|
||||||
|
echo str_repeat("-", 50) . "\n";
|
||||||
|
|
||||||
|
function selectStrategy(NotificationPriority $priority): DispatchStrategy
|
||||||
|
{
|
||||||
|
return match ($priority) {
|
||||||
|
NotificationPriority::URGENT => DispatchStrategy::ALL_OR_NONE, // Critical: all must succeed
|
||||||
|
NotificationPriority::HIGH => DispatchStrategy::FIRST_SUCCESS, // Quick delivery
|
||||||
|
NotificationPriority::NORMAL => DispatchStrategy::FALLBACK, // Graceful degradation
|
||||||
|
NotificationPriority::LOW => DispatchStrategy::ALL, // Best effort
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$urgentNotification = Notification::create(
|
||||||
|
recipientId: 'user_303',
|
||||||
|
type: new SystemNotificationType('payment.failed'),
|
||||||
|
title: 'Payment Failed',
|
||||||
|
body: 'Your payment could not be processed.',
|
||||||
|
NotificationChannel::EMAIL,
|
||||||
|
NotificationChannel::SMS
|
||||||
|
)->withPriority(NotificationPriority::URGENT);
|
||||||
|
|
||||||
|
$strategy = selectStrategy($urgentNotification->priority);
|
||||||
|
echo "Priority: {$urgentNotification->priority->value}\n";
|
||||||
|
echo "Selected Strategy: {$strategy->value}\n";
|
||||||
|
|
||||||
|
$result = $dispatcher->sendNow($urgentNotification, $strategy);
|
||||||
|
echo "Result: " . ($result->isSuccess() ? "✅ SUCCESS" : "❌ FAILURE") . "\n";
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
echo "=== Strategy Summary ===\n";
|
||||||
|
echo "ALL: Send to all channels, continue even if some fail\n";
|
||||||
|
echo " Use case: Non-critical updates, newsletters, marketing\n\n";
|
||||||
|
|
||||||
|
echo "FIRST_SUCCESS: Stop after first successful delivery\n";
|
||||||
|
echo " Use case: Time-sensitive notifications, quick delivery needed\n\n";
|
||||||
|
|
||||||
|
echo "FALLBACK: Try next only if previous failed\n";
|
||||||
|
echo " Use case: Graceful degradation, Telegram -> Email -> SMS chain\n\n";
|
||||||
|
|
||||||
|
echo "ALL_OR_NONE: All must succeed or entire dispatch fails\n";
|
||||||
|
echo " Use case: Critical notifications, legal compliance, account actions\n\n";
|
||||||
|
|
||||||
|
echo "✅ Multi-channel dispatch examples completed\n";
|
||||||
283
examples/notification-rich-media-example.php
Normal file
283
examples/notification-rich-media-example.php
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Framework\Core\AppBootstrapper;
|
||||||
|
use App\Framework\Notification\Channels\TelegramChannel;
|
||||||
|
use App\Framework\Notification\Media\MediaManager;
|
||||||
|
use App\Framework\Notification\ValueObjects\NotificationChannel;
|
||||||
|
use App\Framework\Notification\Notification;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rich Media Notification Example
|
||||||
|
*
|
||||||
|
* Demonstrates the MediaManager system for sending notifications with:
|
||||||
|
* - Photos
|
||||||
|
* - Videos
|
||||||
|
* - Audio files
|
||||||
|
* - Documents
|
||||||
|
* - Location data
|
||||||
|
*/
|
||||||
|
|
||||||
|
echo "🎨 Rich Media Notification System Example\n";
|
||||||
|
echo "==========================================\n\n";
|
||||||
|
|
||||||
|
// Bootstrap application
|
||||||
|
$app = AppBootstrapper::bootstrap();
|
||||||
|
$container = $app->getContainer();
|
||||||
|
|
||||||
|
// Get TelegramChannel with injected MediaManager
|
||||||
|
$telegramChannel = $container->get(TelegramChannel::class);
|
||||||
|
$mediaManager = $telegramChannel->mediaManager;
|
||||||
|
|
||||||
|
// Create sample notification
|
||||||
|
$notification = new Notification(
|
||||||
|
userId: 'user_123',
|
||||||
|
title: 'Rich Media Test',
|
||||||
|
body: 'Testing media capabilities',
|
||||||
|
channel: NotificationChannel::TELEGRAM,
|
||||||
|
type: 'media_test'
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "📋 Testing MediaManager Capabilities\n";
|
||||||
|
echo "------------------------------------\n\n";
|
||||||
|
|
||||||
|
// 1. Check Telegram capabilities
|
||||||
|
echo "1️⃣ Checking Telegram channel capabilities...\n";
|
||||||
|
|
||||||
|
$capabilities = $mediaManager->getCapabilities(NotificationChannel::TELEGRAM);
|
||||||
|
|
||||||
|
echo " Supported media types:\n";
|
||||||
|
echo " - Photos: " . ($capabilities->supportsPhoto ? '✅' : '❌') . "\n";
|
||||||
|
echo " - Videos: " . ($capabilities->supportsVideo ? '✅' : '❌') . "\n";
|
||||||
|
echo " - Audio: " . ($capabilities->supportsAudio ? '✅' : '❌') . "\n";
|
||||||
|
echo " - Documents: " . ($capabilities->supportsDocument ? '✅' : '❌') . "\n";
|
||||||
|
echo " - Location: " . ($capabilities->supportsLocation ? '✅' : '❌') . "\n";
|
||||||
|
echo " - Voice: " . ($capabilities->supportsVoice ? '✅' : '❌') . "\n\n";
|
||||||
|
|
||||||
|
// 2. Test photo support
|
||||||
|
echo "2️⃣ Testing photo support...\n";
|
||||||
|
|
||||||
|
if ($mediaManager->supportsPhoto(NotificationChannel::TELEGRAM)) {
|
||||||
|
echo " ✅ Telegram supports photos\n";
|
||||||
|
|
||||||
|
// Example: Send photo with caption
|
||||||
|
try {
|
||||||
|
$photoNotification = new Notification(
|
||||||
|
userId: 'user_123',
|
||||||
|
title: 'Photo Notification',
|
||||||
|
body: 'Check out this image!',
|
||||||
|
channel: NotificationChannel::TELEGRAM,
|
||||||
|
type: 'photo_test'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: In real usage, you would provide a valid file path or Telegram file_id
|
||||||
|
// $mediaManager->sendPhoto(
|
||||||
|
// NotificationChannel::TELEGRAM,
|
||||||
|
// $photoNotification,
|
||||||
|
// photoPath: '/path/to/image.jpg',
|
||||||
|
// caption: 'Beautiful landscape photo'
|
||||||
|
// );
|
||||||
|
|
||||||
|
echo " 📸 Photo sending method available\n";
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo " ⚠️ Photo test skipped: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " ❌ Telegram does not support photos\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// 3. Test video support
|
||||||
|
echo "3️⃣ Testing video support...\n";
|
||||||
|
|
||||||
|
if ($mediaManager->supportsVideo(NotificationChannel::TELEGRAM)) {
|
||||||
|
echo " ✅ Telegram supports videos\n";
|
||||||
|
|
||||||
|
// Example: Send video with thumbnail
|
||||||
|
try {
|
||||||
|
$videoNotification = new Notification(
|
||||||
|
userId: 'user_123',
|
||||||
|
title: 'Video Notification',
|
||||||
|
body: 'Watch this video!',
|
||||||
|
channel: NotificationChannel::TELEGRAM,
|
||||||
|
type: 'video_test'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: In real usage, you would provide valid file paths
|
||||||
|
// $mediaManager->sendVideo(
|
||||||
|
// NotificationChannel::TELEGRAM,
|
||||||
|
// $videoNotification,
|
||||||
|
// videoPath: '/path/to/video.mp4',
|
||||||
|
// caption: 'Tutorial video',
|
||||||
|
// thumbnailPath: '/path/to/thumbnail.jpg'
|
||||||
|
// );
|
||||||
|
|
||||||
|
echo " 🎥 Video sending method available\n";
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo " ⚠️ Video test skipped: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " ❌ Telegram does not support videos\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// 4. Test audio support
|
||||||
|
echo "4️⃣ Testing audio support...\n";
|
||||||
|
|
||||||
|
if ($mediaManager->supportsAudio(NotificationChannel::TELEGRAM)) {
|
||||||
|
echo " ✅ Telegram supports audio\n";
|
||||||
|
|
||||||
|
// Example: Send audio file
|
||||||
|
try {
|
||||||
|
$audioNotification = new Notification(
|
||||||
|
userId: 'user_123',
|
||||||
|
title: 'Audio Notification',
|
||||||
|
body: 'Listen to this audio!',
|
||||||
|
channel: NotificationChannel::TELEGRAM,
|
||||||
|
type: 'audio_test'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: In real usage, you would provide a valid audio file
|
||||||
|
// $mediaManager->sendAudio(
|
||||||
|
// NotificationChannel::TELEGRAM,
|
||||||
|
// $audioNotification,
|
||||||
|
// audioPath: '/path/to/audio.mp3',
|
||||||
|
// caption: 'Podcast episode',
|
||||||
|
// duration: 300 // 5 minutes
|
||||||
|
// );
|
||||||
|
|
||||||
|
echo " 🎵 Audio sending method available\n";
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo " ⚠️ Audio test skipped: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " ❌ Telegram does not support audio\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// 5. Test document support
|
||||||
|
echo "5️⃣ Testing document support...\n";
|
||||||
|
|
||||||
|
if ($mediaManager->supportsDocument(NotificationChannel::TELEGRAM)) {
|
||||||
|
echo " ✅ Telegram supports documents\n";
|
||||||
|
|
||||||
|
// Example: Send document
|
||||||
|
try {
|
||||||
|
$documentNotification = new Notification(
|
||||||
|
userId: 'user_123',
|
||||||
|
title: 'Document Notification',
|
||||||
|
body: 'Here is your document!',
|
||||||
|
channel: NotificationChannel::TELEGRAM,
|
||||||
|
type: 'document_test'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: In real usage, you would provide a valid document
|
||||||
|
// $mediaManager->sendDocument(
|
||||||
|
// NotificationChannel::TELEGRAM,
|
||||||
|
// $documentNotification,
|
||||||
|
// documentPath: '/path/to/document.pdf',
|
||||||
|
// caption: 'Monthly report',
|
||||||
|
// filename: 'Report_2024.pdf'
|
||||||
|
// );
|
||||||
|
|
||||||
|
echo " 📄 Document sending method available\n";
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo " ⚠️ Document test skipped: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " ❌ Telegram does not support documents\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// 6. Test location support
|
||||||
|
echo "6️⃣ Testing location support...\n";
|
||||||
|
|
||||||
|
if ($mediaManager->supportsLocation(NotificationChannel::TELEGRAM)) {
|
||||||
|
echo " ✅ Telegram supports location sharing\n";
|
||||||
|
|
||||||
|
// Example: Send location
|
||||||
|
try {
|
||||||
|
$locationNotification = new Notification(
|
||||||
|
userId: 'user_123',
|
||||||
|
title: 'Location Notification',
|
||||||
|
body: 'Meet me here!',
|
||||||
|
channel: NotificationChannel::TELEGRAM,
|
||||||
|
type: 'location_test'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: In real usage, you would provide actual coordinates
|
||||||
|
// $mediaManager->sendLocation(
|
||||||
|
// NotificationChannel::TELEGRAM,
|
||||||
|
// $locationNotification,
|
||||||
|
// latitude: 52.5200, // Berlin
|
||||||
|
// longitude: 13.4050,
|
||||||
|
// title: 'Meeting Point',
|
||||||
|
// address: 'Brandenburger Tor, Berlin'
|
||||||
|
// );
|
||||||
|
|
||||||
|
echo " 📍 Location sending method available\n";
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo " ⚠️ Location test skipped: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " ❌ Telegram does not support location sharing\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// 7. Test error handling for unsupported channel
|
||||||
|
echo "7️⃣ Testing error handling for unsupported channel...\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to check capabilities for a channel without registered driver
|
||||||
|
$emailCapabilities = $mediaManager->getCapabilities(NotificationChannel::EMAIL);
|
||||||
|
|
||||||
|
if (!$emailCapabilities->hasAnyMediaSupport()) {
|
||||||
|
echo " ✅ Email channel has no media support (as expected)\n";
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo " ⚠️ Expected behavior: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// 8. Demonstrate runtime capability checking
|
||||||
|
echo "8️⃣ Runtime capability checking pattern...\n";
|
||||||
|
|
||||||
|
$testChannel = NotificationChannel::TELEGRAM;
|
||||||
|
|
||||||
|
echo " Example: Sending media with runtime checks\n";
|
||||||
|
echo " \n";
|
||||||
|
echo " if (\$mediaManager->supportsPhoto(\$channel)) {\n";
|
||||||
|
echo " \$mediaManager->sendPhoto(\$channel, \$notification, \$photoPath);\n";
|
||||||
|
echo " } else {\n";
|
||||||
|
echo " // Fallback to text-only notification\n";
|
||||||
|
echo " \$channel->send(\$notification);\n";
|
||||||
|
echo " }\n";
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
echo "✅ Rich Media System Summary\n";
|
||||||
|
echo "============================\n\n";
|
||||||
|
|
||||||
|
echo "Architecture:\n";
|
||||||
|
echo "- MediaManager: Central management with driver registration\n";
|
||||||
|
echo "- MediaDriver: Marker interface with atomic capability interfaces\n";
|
||||||
|
echo "- Atomic Interfaces: SupportsPhotoAttachments, SupportsVideoAttachments, etc.\n";
|
||||||
|
echo "- TelegramMediaDriver: Full media support implementation\n\n";
|
||||||
|
|
||||||
|
echo "Key Features:\n";
|
||||||
|
echo "- ✅ Runtime capability detection via instanceof\n";
|
||||||
|
echo "- ✅ Type-safe media sending with validation\n";
|
||||||
|
echo "- ✅ Optional media support per channel\n";
|
||||||
|
echo "- ✅ Public MediaManager property on channels\n";
|
||||||
|
echo "- ✅ Graceful degradation for unsupported features\n\n";
|
||||||
|
|
||||||
|
echo "Usage:\n";
|
||||||
|
echo "1. Access MediaManager via channel: \$channel->mediaManager\n";
|
||||||
|
echo "2. Check capabilities before sending: \$mediaManager->supportsPhoto(\$channel)\n";
|
||||||
|
echo "3. Send media with validation: \$mediaManager->sendPhoto(...)\n";
|
||||||
|
echo "4. Handle unsupported media gracefully with fallbacks\n\n";
|
||||||
|
|
||||||
|
echo "✨ Example completed successfully!\n";
|
||||||
309
examples/notification-template-example.php
Normal file
309
examples/notification-template-example.php
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Framework\Notification\Templates\NotificationTemplate;
|
||||||
|
use App\Framework\Notification\Templates\TemplateRenderer;
|
||||||
|
use App\Framework\Notification\Templates\ChannelTemplate;
|
||||||
|
use App\Framework\Notification\Templates\InMemoryTemplateRegistry;
|
||||||
|
use App\Framework\Notification\ValueObjects\NotificationChannel;
|
||||||
|
use App\Framework\Notification\ValueObjects\NotificationPriority;
|
||||||
|
use App\Framework\Notification\ValueObjects\SystemNotificationType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification Template System Example
|
||||||
|
*
|
||||||
|
* Demonstrates:
|
||||||
|
* - Template creation with placeholders
|
||||||
|
* - Variable substitution
|
||||||
|
* - Per-channel customization
|
||||||
|
* - Template registry
|
||||||
|
* - Required and default variables
|
||||||
|
*/
|
||||||
|
|
||||||
|
echo "=== Notification Template System Examples ===\n\n";
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
$registry = new InMemoryTemplateRegistry();
|
||||||
|
$renderer = new TemplateRenderer();
|
||||||
|
|
||||||
|
// Example 1: Basic Template with Simple Placeholders
|
||||||
|
echo "1. Basic Template - Order Shipped\n";
|
||||||
|
echo str_repeat("-", 50) . "\n";
|
||||||
|
|
||||||
|
$orderShippedTemplate = NotificationTemplate::create(
|
||||||
|
name: 'order.shipped',
|
||||||
|
titleTemplate: 'Order {{order_id}} Shipped',
|
||||||
|
bodyTemplate: 'Your order {{order_id}} has been shipped and will arrive by {{delivery_date}}. Track your package: {{tracking_url}}'
|
||||||
|
)->withPriority(NotificationPriority::HIGH)
|
||||||
|
->withRequiredVariables('order_id', 'delivery_date', 'tracking_url');
|
||||||
|
|
||||||
|
$registry->register($orderShippedTemplate);
|
||||||
|
|
||||||
|
// Render notification
|
||||||
|
$notification = $renderer->render(
|
||||||
|
template: $orderShippedTemplate,
|
||||||
|
recipientId: 'user_123',
|
||||||
|
variables: [
|
||||||
|
'order_id' => '#12345',
|
||||||
|
'delivery_date' => 'December 25, 2024',
|
||||||
|
'tracking_url' => 'https://example.com/track/ABC123',
|
||||||
|
],
|
||||||
|
channels: [NotificationChannel::EMAIL, NotificationChannel::TELEGRAM],
|
||||||
|
type: new SystemNotificationType('order.shipped')
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "Title: {$notification->title}\n";
|
||||||
|
echo "Body: {$notification->body}\n";
|
||||||
|
echo "Priority: {$notification->priority->value}\n";
|
||||||
|
echo "Template Data: " . json_encode($notification->data, JSON_PRETTY_PRINT) . "\n";
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 2: Template with Nested Variables
|
||||||
|
echo "2. Nested Variables - User Welcome\n";
|
||||||
|
echo str_repeat("-", 50) . "\n";
|
||||||
|
|
||||||
|
$welcomeTemplate = NotificationTemplate::create(
|
||||||
|
name: 'user.welcome',
|
||||||
|
titleTemplate: 'Welcome to {{app.name}}, {{user.name}}!',
|
||||||
|
bodyTemplate: 'Hi {{user.name}}, welcome to {{app.name}}! Your account has been created successfully. Get started here: {{app.url}}'
|
||||||
|
)->withRequiredVariables('user.name')
|
||||||
|
->withDefaultVariables([
|
||||||
|
'app' => [
|
||||||
|
'name' => 'My Application',
|
||||||
|
'url' => 'https://example.com/start',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$registry->register($welcomeTemplate);
|
||||||
|
|
||||||
|
$notification = $renderer->render(
|
||||||
|
template: $welcomeTemplate,
|
||||||
|
recipientId: 'user_456',
|
||||||
|
variables: [
|
||||||
|
'user' => [
|
||||||
|
'name' => 'John Doe',
|
||||||
|
'email' => 'john@example.com',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
channels: [NotificationChannel::EMAIL],
|
||||||
|
type: new SystemNotificationType('user.welcome')
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "Title: {$notification->title}\n";
|
||||||
|
echo "Body: {$notification->body}\n";
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 3: Per-Channel Customization
|
||||||
|
echo "3. Per-Channel Templates - Different Formats\n";
|
||||||
|
echo str_repeat("-", 50) . "\n";
|
||||||
|
|
||||||
|
$securityAlertTemplate = NotificationTemplate::create(
|
||||||
|
name: 'security.alert',
|
||||||
|
titleTemplate: 'Security Alert',
|
||||||
|
bodyTemplate: 'Unusual login activity detected from {{ip_address}} at {{time}}.'
|
||||||
|
)->withPriority(NotificationPriority::URGENT);
|
||||||
|
|
||||||
|
// Telegram: Use Markdown formatting
|
||||||
|
$telegramTemplate = ChannelTemplate::create(
|
||||||
|
titleTemplate: '🔒 *Security Alert*',
|
||||||
|
bodyTemplate: '⚠️ Unusual login activity detected:\n\n📍 IP: `{{ip_address}}`\n⏰ Time: {{time}}\n\nIf this wasn\'t you, secure your account immediately!'
|
||||||
|
)->withMetadata(['parse_mode' => 'Markdown']);
|
||||||
|
|
||||||
|
// Email: Use HTML formatting
|
||||||
|
$emailTemplate = ChannelTemplate::create(
|
||||||
|
titleTemplate: '🔒 Security Alert',
|
||||||
|
bodyTemplate: '<h2>Unusual Login Activity</h2><p>We detected a login from <strong>{{ip_address}}</strong> at {{time}}.</p><p>If this wasn\'t you, please secure your account immediately.</p>'
|
||||||
|
)->withMetadata(['content_type' => 'text/html']);
|
||||||
|
|
||||||
|
// SMS: Keep it short and plain
|
||||||
|
$smsTemplate = ChannelTemplate::create(
|
||||||
|
bodyTemplate: 'SECURITY ALERT: Login from {{ip_address}} at {{time}}. If not you, secure account now.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$securityAlertTemplate = $securityAlertTemplate
|
||||||
|
->withChannelTemplate(NotificationChannel::TELEGRAM, $telegramTemplate)
|
||||||
|
->withChannelTemplate(NotificationChannel::EMAIL, $emailTemplate)
|
||||||
|
->withChannelTemplate(NotificationChannel::SMS, $smsTemplate);
|
||||||
|
|
||||||
|
$registry->register($securityAlertTemplate);
|
||||||
|
|
||||||
|
// Render for each channel
|
||||||
|
$variables = [
|
||||||
|
'ip_address' => '203.0.113.42',
|
||||||
|
'time' => '2024-12-19 15:30:00 UTC',
|
||||||
|
];
|
||||||
|
|
||||||
|
echo "TELEGRAM VERSION:\n";
|
||||||
|
$telegramContent = $renderer->renderForChannel(
|
||||||
|
$securityAlertTemplate,
|
||||||
|
NotificationChannel::TELEGRAM,
|
||||||
|
$variables
|
||||||
|
);
|
||||||
|
echo "Title: {$telegramContent->title}\n";
|
||||||
|
echo "Body:\n{$telegramContent->body}\n";
|
||||||
|
echo "Metadata: " . json_encode($telegramContent->metadata) . "\n\n";
|
||||||
|
|
||||||
|
echo "EMAIL VERSION:\n";
|
||||||
|
$emailContent = $renderer->renderForChannel(
|
||||||
|
$securityAlertTemplate,
|
||||||
|
NotificationChannel::EMAIL,
|
||||||
|
$variables
|
||||||
|
);
|
||||||
|
echo "Title: {$emailContent->title}\n";
|
||||||
|
echo "Body:\n{$emailContent->body}\n";
|
||||||
|
echo "Metadata: " . json_encode($emailContent->metadata) . "\n\n";
|
||||||
|
|
||||||
|
echo "SMS VERSION:\n";
|
||||||
|
$smsContent = $renderer->renderForChannel(
|
||||||
|
$securityAlertTemplate,
|
||||||
|
NotificationChannel::SMS,
|
||||||
|
$variables
|
||||||
|
);
|
||||||
|
echo "Body: {$smsContent->body}\n";
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 4: Template with Default Variables
|
||||||
|
echo "4. Default Variables - Newsletter Template\n";
|
||||||
|
echo str_repeat("-", 50) . "\n";
|
||||||
|
|
||||||
|
$newsletterTemplate = NotificationTemplate::create(
|
||||||
|
name: 'newsletter.weekly',
|
||||||
|
titleTemplate: '{{newsletter.title}} - Week {{week_number}}',
|
||||||
|
bodyTemplate: 'Hi {{user.name}}, here\'s your weekly {{newsletter.title}}! This week\'s highlights: {{highlights}}. Read more: {{newsletter.url}}'
|
||||||
|
)->withDefaultVariables([
|
||||||
|
'newsletter' => [
|
||||||
|
'title' => 'Weekly Update',
|
||||||
|
'url' => 'https://example.com/newsletter',
|
||||||
|
],
|
||||||
|
'highlights' => 'New features, bug fixes, and improvements',
|
||||||
|
])->withRequiredVariables('user.name', 'week_number');
|
||||||
|
|
||||||
|
$registry->register($newsletterTemplate);
|
||||||
|
|
||||||
|
$notification = $renderer->render(
|
||||||
|
template: $newsletterTemplate,
|
||||||
|
recipientId: 'user_789',
|
||||||
|
variables: [
|
||||||
|
'user' => ['name' => 'Jane Smith'],
|
||||||
|
'week_number' => '51',
|
||||||
|
// Using default values for newsletter and highlights
|
||||||
|
],
|
||||||
|
channels: [NotificationChannel::EMAIL],
|
||||||
|
type: new SystemNotificationType('newsletter.weekly')
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "Title: {$notification->title}\n";
|
||||||
|
echo "Body: {$notification->body}\n";
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 5: Template Registry Usage
|
||||||
|
echo "5. Template Registry - Lookup and Reuse\n";
|
||||||
|
echo str_repeat("-", 50) . "\n";
|
||||||
|
|
||||||
|
echo "Registered templates:\n";
|
||||||
|
foreach ($registry->all() as $name => $template) {
|
||||||
|
echo " - {$name} (Priority: {$template->defaultPriority->value})\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Reuse template from registry
|
||||||
|
$template = $registry->get('order.shipped');
|
||||||
|
if ($template !== null) {
|
||||||
|
echo "Retrieved template: {$template->name}\n";
|
||||||
|
echo "Required variables: " . implode(', ', $template->requiredVariables) . "\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 6: Error Handling - Missing Required Variable
|
||||||
|
echo "6. Error Handling - Validation\n";
|
||||||
|
echo str_repeat("-", 50) . "\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$renderer->render(
|
||||||
|
template: $orderShippedTemplate,
|
||||||
|
recipientId: 'user_999',
|
||||||
|
variables: [
|
||||||
|
'order_id' => '#67890',
|
||||||
|
// Missing 'delivery_date' and 'tracking_url'
|
||||||
|
],
|
||||||
|
channels: [NotificationChannel::EMAIL],
|
||||||
|
type: new SystemNotificationType('order.shipped')
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
echo "❌ Validation Error: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 7: Complex Object in Variables
|
||||||
|
echo "7. Complex Objects - Value Object Support\n";
|
||||||
|
echo str_repeat("-", 50) . "\n";
|
||||||
|
|
||||||
|
$paymentTemplate = NotificationTemplate::create(
|
||||||
|
name: 'payment.received',
|
||||||
|
titleTemplate: 'Payment Received',
|
||||||
|
bodyTemplate: 'We received your payment of {{amount}} on {{date}}. Transaction ID: {{transaction.id}}'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create notification with object variables
|
||||||
|
$notification = $renderer->render(
|
||||||
|
template: $paymentTemplate,
|
||||||
|
recipientId: 'user_101',
|
||||||
|
variables: [
|
||||||
|
'amount' => '$99.00',
|
||||||
|
'date' => '2024-12-19',
|
||||||
|
'transaction' => [
|
||||||
|
'id' => 'TXN_123456',
|
||||||
|
'status' => 'completed',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
channels: [NotificationChannel::EMAIL, NotificationChannel::TELEGRAM],
|
||||||
|
type: new SystemNotificationType('payment.received')
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "Title: {$notification->title}\n";
|
||||||
|
echo "Body: {$notification->body}\n";
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 8: Integration with NotificationDispatcher
|
||||||
|
echo "8. Template + Dispatcher Integration\n";
|
||||||
|
echo str_repeat("-", 50) . "\n";
|
||||||
|
|
||||||
|
echo "Step 1: Create template\n";
|
||||||
|
$template = NotificationTemplate::create(
|
||||||
|
name: 'account.deleted',
|
||||||
|
titleTemplate: 'Account Deletion Confirmation',
|
||||||
|
bodyTemplate: 'Your account {{username}} has been permanently deleted on {{deletion_date}}.'
|
||||||
|
)->withPriority(NotificationPriority::URGENT);
|
||||||
|
|
||||||
|
echo "Step 2: Render notification from template\n";
|
||||||
|
$notification = $renderer->render(
|
||||||
|
template: $template,
|
||||||
|
recipientId: 'user_202',
|
||||||
|
variables: [
|
||||||
|
'username' => 'johndoe',
|
||||||
|
'deletion_date' => '2024-12-19',
|
||||||
|
],
|
||||||
|
channels: [NotificationChannel::EMAIL, NotificationChannel::SMS],
|
||||||
|
type: new SystemNotificationType('account.deleted')
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "Step 3: Dispatch via NotificationDispatcher\n";
|
||||||
|
echo " (In real app: \$dispatcher->sendNow(\$notification, DispatchStrategy::ALL_OR_NONE))\n";
|
||||||
|
echo " Notification ready for dispatch:\n";
|
||||||
|
echo " - Title: {$notification->title}\n";
|
||||||
|
echo " - Channels: " . count($notification->channels) . "\n";
|
||||||
|
echo " - Priority: {$notification->priority->value}\n";
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
echo "=== Template System Summary ===\n";
|
||||||
|
echo "✅ Created " . count($registry->all()) . " templates\n";
|
||||||
|
echo "✅ Demonstrated placeholder substitution ({{variable}})\n";
|
||||||
|
echo "✅ Demonstrated nested variables ({{user.name}})\n";
|
||||||
|
echo "✅ Demonstrated per-channel customization\n";
|
||||||
|
echo "✅ Demonstrated default variables\n";
|
||||||
|
echo "✅ Demonstrated validation and error handling\n";
|
||||||
|
echo "✅ Template system ready for production use\n";
|
||||||
192
examples/scheduled-job-example.php
Normal file
192
examples/scheduled-job-example.php
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Framework\Worker\Every;
|
||||||
|
use App\Framework\Worker\Schedule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Scheduled Job that runs every 5 minutes
|
||||||
|
*
|
||||||
|
* The #[Schedule] attribute marks this class for automatic registration
|
||||||
|
* with the Worker's scheduler system.
|
||||||
|
*
|
||||||
|
* The Worker will:
|
||||||
|
* 1. Discover this class via ScheduleDiscoveryService on startup
|
||||||
|
* 2. Register it with SchedulerService using an IntervalSchedule
|
||||||
|
* 3. Execute the handle() method every 5 minutes
|
||||||
|
*/
|
||||||
|
#[Schedule(at: new Every(minutes: 5))]
|
||||||
|
final class CleanupTempFilesJob
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* This method is called by the scheduler when the job is due
|
||||||
|
*/
|
||||||
|
public function handle(): array
|
||||||
|
{
|
||||||
|
echo "[" . date('Y-m-d H:i:s') . "] Running CleanupTempFilesJob\n";
|
||||||
|
|
||||||
|
// Your cleanup logic here
|
||||||
|
$deletedFiles = $this->cleanupOldTempFiles();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'deleted_files' => $deletedFiles,
|
||||||
|
'executed_at' => time()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanupOldTempFiles(): int
|
||||||
|
{
|
||||||
|
// Example cleanup logic
|
||||||
|
$tempDir = sys_get_temp_dir();
|
||||||
|
$deletedCount = 0;
|
||||||
|
|
||||||
|
// Delete files older than 1 hour
|
||||||
|
$files = glob($tempDir . '/*.tmp');
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (file_exists($file) && (time() - filemtime($file)) > 3600) {
|
||||||
|
unlink($file);
|
||||||
|
$deletedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $deletedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Hourly data aggregation job
|
||||||
|
*
|
||||||
|
* This job runs every hour and aggregates analytics data
|
||||||
|
*/
|
||||||
|
#[Schedule(at: new Every(hours: 1))]
|
||||||
|
final class AggregateAnalyticsJob
|
||||||
|
{
|
||||||
|
public function handle(): array
|
||||||
|
{
|
||||||
|
echo "[" . date('Y-m-d H:i:s') . "] Running AggregateAnalyticsJob\n";
|
||||||
|
|
||||||
|
// Your aggregation logic here
|
||||||
|
$recordsProcessed = $this->aggregateLastHourData();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'records_processed' => $recordsProcessed,
|
||||||
|
'executed_at' => time()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function aggregateLastHourData(): int
|
||||||
|
{
|
||||||
|
// Example aggregation logic
|
||||||
|
return rand(100, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Daily backup job
|
||||||
|
*
|
||||||
|
* This job runs once per day
|
||||||
|
*/
|
||||||
|
#[Schedule(at: new Every(days: 1))]
|
||||||
|
final class DailyBackupJob
|
||||||
|
{
|
||||||
|
public function handle(): array
|
||||||
|
{
|
||||||
|
echo "[" . date('Y-m-d H:i:s') . "] Running DailyBackupJob\n";
|
||||||
|
|
||||||
|
// Your backup logic here
|
||||||
|
$backupSize = $this->createDatabaseBackup();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'backup_size_mb' => $backupSize,
|
||||||
|
'executed_at' => time()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createDatabaseBackup(): float
|
||||||
|
{
|
||||||
|
// Example backup logic
|
||||||
|
return round(rand(50, 200) / 10, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Callable job (using __invoke)
|
||||||
|
*
|
||||||
|
* Jobs can also be callable instead of using handle() method
|
||||||
|
*/
|
||||||
|
#[Schedule(at: new Every(minutes: 10))]
|
||||||
|
final class MonitorSystemHealthJob
|
||||||
|
{
|
||||||
|
public function __invoke(): string
|
||||||
|
{
|
||||||
|
echo "[" . date('Y-m-d H:i:s') . "] Running MonitorSystemHealthJob\n";
|
||||||
|
|
||||||
|
$memoryUsage = memory_get_usage(true) / 1024 / 1024;
|
||||||
|
$cpuLoad = sys_getloadavg()[0];
|
||||||
|
|
||||||
|
return "System healthy - Memory: {$memoryUsage}MB, CPU Load: {$cpuLoad}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Complex schedule with multiple time units
|
||||||
|
*/
|
||||||
|
#[Schedule(at: new Every(days: 1, hours: 2, minutes: 30))]
|
||||||
|
final class WeeklyReportJob
|
||||||
|
{
|
||||||
|
public function handle(): array
|
||||||
|
{
|
||||||
|
echo "[" . date('Y-m-d H:i:s') . "] Running WeeklyReportJob\n";
|
||||||
|
|
||||||
|
// This runs every 1 day, 2 hours, 30 minutes
|
||||||
|
// Total: (1 * 86400) + (2 * 3600) + (30 * 60) = 94200 seconds
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'report_generated' => true,
|
||||||
|
'executed_at' => time()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo <<<'INFO'
|
||||||
|
=== Scheduled Jobs Example ===
|
||||||
|
|
||||||
|
This example shows how to create scheduled jobs using the #[Schedule] attribute.
|
||||||
|
|
||||||
|
How it works:
|
||||||
|
1. Mark your job class with #[Schedule(at: new Every(...))]
|
||||||
|
2. Implement either a handle() method or make your class callable (__invoke)
|
||||||
|
3. The Worker will automatically discover and register your job on startup
|
||||||
|
4. The job will execute at the specified interval
|
||||||
|
|
||||||
|
Available Every time units:
|
||||||
|
- Every(days: 1) - Run once per day
|
||||||
|
- Every(hours: 1) - Run once per hour
|
||||||
|
- Every(minutes: 5) - Run every 5 minutes
|
||||||
|
- Every(seconds: 30) - Run every 30 seconds
|
||||||
|
- Combine multiple units: Every(days: 1, hours: 2, minutes: 30)
|
||||||
|
|
||||||
|
Task ID Generation:
|
||||||
|
Job class names are automatically converted to kebab-case task IDs:
|
||||||
|
- CleanupTempFilesJob -> cleanup-temp-files-job
|
||||||
|
- AggregateAnalyticsJob -> aggregate-analytics-job
|
||||||
|
- DailyBackupJob -> daily-backup-job
|
||||||
|
|
||||||
|
Starting the Worker:
|
||||||
|
To run these scheduled jobs, start the Worker:
|
||||||
|
docker exec php php console.php worker:start
|
||||||
|
|
||||||
|
The Worker will:
|
||||||
|
- Discover all classes with #[Schedule] attribute
|
||||||
|
- Register them with the SchedulerService
|
||||||
|
- Check for due tasks every 10 seconds
|
||||||
|
- Execute tasks and log results
|
||||||
|
|
||||||
|
INFO;
|
||||||
212
examples/send-telegram-media-example.php
Normal file
212
examples/send-telegram-media-example.php
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Framework\Core\AppBootstrapper;
|
||||||
|
use App\Framework\Notification\Channels\TelegramChannel;
|
||||||
|
use App\Framework\Notification\ValueObjects\NotificationChannel;
|
||||||
|
use App\Framework\Notification\Notification;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Practical Telegram Media Sending Example
|
||||||
|
*
|
||||||
|
* This script demonstrates actual media sending via Telegram
|
||||||
|
* using the MediaManager system
|
||||||
|
*/
|
||||||
|
|
||||||
|
echo "📱 Telegram Rich Media Sending Example\n";
|
||||||
|
echo "=======================================\n\n";
|
||||||
|
|
||||||
|
// Bootstrap application
|
||||||
|
$app = AppBootstrapper::bootstrap();
|
||||||
|
$container = $app->getContainer();
|
||||||
|
|
||||||
|
// Get Telegram channel with MediaManager
|
||||||
|
$telegramChannel = $container->get(TelegramChannel::class);
|
||||||
|
$mediaManager = $telegramChannel->mediaManager;
|
||||||
|
|
||||||
|
// Check Telegram capabilities
|
||||||
|
echo "📋 Telegram Media Capabilities:\n";
|
||||||
|
$capabilities = $mediaManager->getCapabilities(NotificationChannel::TELEGRAM);
|
||||||
|
echo " Photos: " . ($capabilities->supportsPhoto ? '✅' : '❌') . "\n";
|
||||||
|
echo " Videos: " . ($capabilities->supportsVideo ? '✅' : '❌') . "\n";
|
||||||
|
echo " Audio: " . ($capabilities->supportsAudio ? '✅' : '❌') . "\n";
|
||||||
|
echo " Documents: " . ($capabilities->supportsDocument ? '✅' : '❌') . "\n";
|
||||||
|
echo " Location: " . ($capabilities->supportsLocation ? '✅' : '❌') . "\n\n";
|
||||||
|
|
||||||
|
// Create notification
|
||||||
|
$notification = new Notification(
|
||||||
|
userId: 'user_123',
|
||||||
|
title: 'Media Test',
|
||||||
|
body: 'Testing Telegram media capabilities',
|
||||||
|
channel: NotificationChannel::TELEGRAM,
|
||||||
|
type: 'media_demo'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Example 1: Send Photo
|
||||||
|
echo "1️⃣ Sending photo...\n";
|
||||||
|
try {
|
||||||
|
if ($mediaManager->supportsPhoto(NotificationChannel::TELEGRAM)) {
|
||||||
|
// Using Telegram's sample photo URL for testing
|
||||||
|
// In production, use local file paths or previously uploaded file_id
|
||||||
|
$photoUrl = 'https://api.telegram.org/file/bot<token>/photos/file_0.jpg';
|
||||||
|
|
||||||
|
$photoNotification = new Notification(
|
||||||
|
userId: 'user_123',
|
||||||
|
title: 'Photo Notification',
|
||||||
|
body: 'This is a test photo',
|
||||||
|
channel: NotificationChannel::TELEGRAM,
|
||||||
|
type: 'photo'
|
||||||
|
);
|
||||||
|
|
||||||
|
$mediaManager->sendPhoto(
|
||||||
|
NotificationChannel::TELEGRAM,
|
||||||
|
$photoNotification,
|
||||||
|
photoPath: $photoUrl, // Can be URL, file path, or file_id
|
||||||
|
caption: '📸 Test photo from MediaManager'
|
||||||
|
);
|
||||||
|
|
||||||
|
echo " ✅ Photo sent successfully\n";
|
||||||
|
} else {
|
||||||
|
echo " ❌ Photo not supported\n";
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo " ⚠️ Error: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 2: Send Location
|
||||||
|
echo "2️⃣ Sending location...\n";
|
||||||
|
try {
|
||||||
|
if ($mediaManager->supportsLocation(NotificationChannel::TELEGRAM)) {
|
||||||
|
$locationNotification = new Notification(
|
||||||
|
userId: 'user_123',
|
||||||
|
title: 'Location Share',
|
||||||
|
body: 'Meeting point',
|
||||||
|
channel: NotificationChannel::TELEGRAM,
|
||||||
|
type: 'location'
|
||||||
|
);
|
||||||
|
|
||||||
|
$mediaManager->sendLocation(
|
||||||
|
NotificationChannel::TELEGRAM,
|
||||||
|
$locationNotification,
|
||||||
|
latitude: 52.5200, // Berlin
|
||||||
|
longitude: 13.4050,
|
||||||
|
title: 'Brandenburger Tor',
|
||||||
|
address: '10117 Berlin, Germany'
|
||||||
|
);
|
||||||
|
|
||||||
|
echo " ✅ Location sent successfully\n";
|
||||||
|
} else {
|
||||||
|
echo " ❌ Location not supported\n";
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo " ⚠️ Error: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 3: Send Document
|
||||||
|
echo "3️⃣ Sending document...\n";
|
||||||
|
try {
|
||||||
|
if ($mediaManager->supportsDocument(NotificationChannel::TELEGRAM)) {
|
||||||
|
$documentNotification = new Notification(
|
||||||
|
userId: 'user_123',
|
||||||
|
title: 'Document Share',
|
||||||
|
body: 'Important document',
|
||||||
|
channel: NotificationChannel::TELEGRAM,
|
||||||
|
type: 'document'
|
||||||
|
);
|
||||||
|
|
||||||
|
// In production, use actual file path
|
||||||
|
// $mediaManager->sendDocument(
|
||||||
|
// NotificationChannel::TELEGRAM,
|
||||||
|
// $documentNotification,
|
||||||
|
// documentPath: '/path/to/document.pdf',
|
||||||
|
// caption: '📄 Monthly Report',
|
||||||
|
// filename: 'report_2024.pdf'
|
||||||
|
// );
|
||||||
|
|
||||||
|
echo " ℹ️ Document example (requires actual file path)\n";
|
||||||
|
} else {
|
||||||
|
echo " ❌ Document not supported\n";
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo " ⚠️ Error: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 4: Graceful fallback to text-only
|
||||||
|
echo "4️⃣ Demonstrating graceful fallback...\n";
|
||||||
|
try {
|
||||||
|
$fallbackNotification = new Notification(
|
||||||
|
userId: 'user_123',
|
||||||
|
title: 'Fallback Test',
|
||||||
|
body: 'This notification tries to send media, but falls back to text if unsupported',
|
||||||
|
channel: NotificationChannel::TELEGRAM,
|
||||||
|
type: 'fallback'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to send with photo, fallback to text
|
||||||
|
if ($mediaManager->supportsPhoto(NotificationChannel::TELEGRAM)) {
|
||||||
|
echo " Attempting to send with photo...\n";
|
||||||
|
// $mediaManager->sendPhoto(...);
|
||||||
|
echo " ✅ Would send photo if file path provided\n";
|
||||||
|
} else {
|
||||||
|
echo " Photo not supported, falling back to text notification...\n";
|
||||||
|
$telegramChannel->send($fallbackNotification);
|
||||||
|
echo " ✅ Text notification sent as fallback\n";
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo " ⚠️ Error: {$e->getMessage()}\n";
|
||||||
|
echo " Falling back to text notification...\n";
|
||||||
|
$telegramChannel->send($fallbackNotification);
|
||||||
|
echo " ✅ Fallback successful\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
// Example 5: Using MediaCapabilities for multi-media notifications
|
||||||
|
echo "5️⃣ Smart multi-media notification...\n";
|
||||||
|
|
||||||
|
$multiMediaNotification = new Notification(
|
||||||
|
userId: 'user_123',
|
||||||
|
title: 'Order Confirmed',
|
||||||
|
body: 'Your order #12345 has been confirmed',
|
||||||
|
channel: NotificationChannel::TELEGRAM,
|
||||||
|
type: 'order_confirmed'
|
||||||
|
);
|
||||||
|
|
||||||
|
$capabilities = $mediaManager->getCapabilities(NotificationChannel::TELEGRAM);
|
||||||
|
|
||||||
|
if ($capabilities->supportsPhoto) {
|
||||||
|
echo " 📸 Could attach product photo\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($capabilities->supportsDocument) {
|
||||||
|
echo " 📄 Could attach order receipt PDF\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($capabilities->supportsLocation) {
|
||||||
|
echo " 📍 Could share delivery location\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo " ✅ Multi-media notification planned\n\n";
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
echo "✨ Summary\n";
|
||||||
|
echo "=========\n\n";
|
||||||
|
|
||||||
|
echo "MediaManager provides:\n";
|
||||||
|
echo "- Runtime capability checking before sending\n";
|
||||||
|
echo "- Type-safe media sending methods\n";
|
||||||
|
echo "- Graceful fallback support\n";
|
||||||
|
echo "- Unified API across all channels\n\n";
|
||||||
|
|
||||||
|
echo "Best Practices:\n";
|
||||||
|
echo "1. Always check capabilities before sending media\n";
|
||||||
|
echo "2. Provide fallback to text notifications\n";
|
||||||
|
echo "3. Handle exceptions gracefully\n";
|
||||||
|
echo "4. Use appropriate media types for context\n\n";
|
||||||
|
|
||||||
|
echo "✅ Example completed!\n";
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Application\Admin\MachineLearning;
|
||||||
|
|
||||||
|
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||||
|
use App\Framework\Attributes\Route;
|
||||||
|
use App\Framework\Auth\Auth;
|
||||||
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
use App\Framework\Http\HttpRequest;
|
||||||
|
use App\Framework\Http\Method;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ModelPerformanceMonitor;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ModelRegistry;
|
||||||
|
use App\Framework\Meta\MetaData;
|
||||||
|
use App\Framework\Router\AdminRoutes;
|
||||||
|
use App\Framework\Router\Result\ViewResult;
|
||||||
|
|
||||||
|
final readonly class MLDashboardAdminController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ModelRegistry $registry,
|
||||||
|
private ModelPerformanceMonitor $performanceMonitor,
|
||||||
|
private AdminLayoutProcessor $layoutProcessor
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Auth]
|
||||||
|
#[Route(path: '/admin/ml/dashboard', method: Method::GET, name: AdminRoutes::ML_DASHBOARD)]
|
||||||
|
public function dashboard(HttpRequest $request): ViewResult
|
||||||
|
{
|
||||||
|
$timeWindowHours = (int) ($request->queryParameters['timeWindow'] ?? 24);
|
||||||
|
$timeWindow = Duration::fromHours($timeWindowHours);
|
||||||
|
|
||||||
|
// Get all models
|
||||||
|
$allModels = $this->getAllModels();
|
||||||
|
|
||||||
|
// Collect performance overview
|
||||||
|
$performanceOverview = [];
|
||||||
|
$totalPredictions = 0;
|
||||||
|
$accuracySum = 0.0;
|
||||||
|
$healthyCount = 0;
|
||||||
|
$degradedCount = 0;
|
||||||
|
$criticalCount = 0;
|
||||||
|
|
||||||
|
foreach ($allModels as $metadata) {
|
||||||
|
$metrics = $this->performanceMonitor->getCurrentMetrics(
|
||||||
|
$metadata->modelName,
|
||||||
|
$metadata->version,
|
||||||
|
$timeWindow
|
||||||
|
);
|
||||||
|
|
||||||
|
$accuracy = $metrics['accuracy'];
|
||||||
|
$isHealthy = $accuracy >= 0.85;
|
||||||
|
$isCritical = $accuracy < 0.7;
|
||||||
|
|
||||||
|
if ($isHealthy) {
|
||||||
|
$healthyCount++;
|
||||||
|
} elseif ($isCritical) {
|
||||||
|
$criticalCount++;
|
||||||
|
} else {
|
||||||
|
$degradedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$performanceOverview[] = [
|
||||||
|
'model_name' => $metadata->modelName,
|
||||||
|
'version' => $metadata->version->toString(),
|
||||||
|
'type' => $metadata->modelType->value,
|
||||||
|
'accuracy' => round($accuracy * 100, 2),
|
||||||
|
'precision' => isset($metrics['precision']) ? round($metrics['precision'] * 100, 2) : null,
|
||||||
|
'recall' => isset($metrics['recall']) ? round($metrics['recall'] * 100, 2) : null,
|
||||||
|
'f1_score' => isset($metrics['f1_score']) ? round($metrics['f1_score'] * 100, 2) : null,
|
||||||
|
'total_predictions' => number_format($metrics['total_predictions']),
|
||||||
|
'average_confidence' => isset($metrics['average_confidence']) ? round($metrics['average_confidence'] * 100, 2) : null,
|
||||||
|
'threshold' => $metadata->configuration['threshold'] ?? null,
|
||||||
|
'status' => $isHealthy ? 'healthy' : ($isCritical ? 'critical' : 'degraded'),
|
||||||
|
'status_badge' => $isHealthy ? 'success' : ($isCritical ? 'danger' : 'warning'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$totalPredictions += $metrics['total_predictions'];
|
||||||
|
$accuracySum += $accuracy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate degradation alerts
|
||||||
|
$degradationAlerts = [];
|
||||||
|
foreach ($performanceOverview as $model) {
|
||||||
|
if ($model['status'] !== 'healthy') {
|
||||||
|
$degradationAlerts[] = [
|
||||||
|
'model_name' => $model['model_name'],
|
||||||
|
'version' => $model['version'],
|
||||||
|
'current_accuracy' => $model['accuracy'],
|
||||||
|
'threshold' => 85.0,
|
||||||
|
'severity' => $model['status'],
|
||||||
|
'severity_badge' => $model['status_badge'],
|
||||||
|
'recommendation' => 'Consider retraining or rolling back to previous version',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate health indicators
|
||||||
|
$modelCount = count($allModels);
|
||||||
|
$averageAccuracy = $modelCount > 0 ? ($accuracySum / $modelCount) * 100 : 0.0;
|
||||||
|
$healthPercentage = $modelCount > 0 ? ($healthyCount / $modelCount) * 100 : 0.0;
|
||||||
|
$overallStatus = $criticalCount > 0 ? 'critical' : ($degradedCount > $modelCount / 2 ? 'warning' : 'healthy');
|
||||||
|
$overallBadge = $criticalCount > 0 ? 'danger' : ($degradedCount > $modelCount / 2 ? 'warning' : 'success');
|
||||||
|
|
||||||
|
// Count by type
|
||||||
|
$byType = [
|
||||||
|
'supervised' => 0,
|
||||||
|
'unsupervised' => 0,
|
||||||
|
'reinforcement' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($allModels as $metadata) {
|
||||||
|
$typeName = strtolower($metadata->modelType->value);
|
||||||
|
$byType[$typeName] = ($byType[$typeName] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'title' => 'ML Model Dashboard',
|
||||||
|
'page_title' => 'Machine Learning Model Dashboard',
|
||||||
|
'current_path' => '/admin/ml/dashboard',
|
||||||
|
'time_window_hours' => $timeWindowHours,
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
'total_models' => $modelCount,
|
||||||
|
'healthy_models' => $healthyCount,
|
||||||
|
'degraded_models' => $degradedCount,
|
||||||
|
'critical_models' => $criticalCount,
|
||||||
|
'total_predictions' => number_format($totalPredictions),
|
||||||
|
'average_accuracy' => round($averageAccuracy, 2),
|
||||||
|
'health_percentage' => round($healthPercentage, 2),
|
||||||
|
'overall_status' => ucfirst($overallStatus),
|
||||||
|
'overall_badge' => $overallBadge,
|
||||||
|
|
||||||
|
// Type distribution
|
||||||
|
'supervised_count' => $byType['supervised'],
|
||||||
|
'unsupervised_count' => $byType['unsupervised'],
|
||||||
|
'reinforcement_count' => $byType['reinforcement'],
|
||||||
|
|
||||||
|
// Models and alerts
|
||||||
|
'models' => $performanceOverview,
|
||||||
|
'alerts' => $degradationAlerts,
|
||||||
|
'has_alerts' => count($degradationAlerts) > 0,
|
||||||
|
'alert_count' => count($degradationAlerts),
|
||||||
|
|
||||||
|
// Links
|
||||||
|
'api_dashboard_url' => '/api/ml/dashboard',
|
||||||
|
'api_health_url' => '/api/ml/dashboard/health',
|
||||||
|
];
|
||||||
|
|
||||||
|
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
|
||||||
|
|
||||||
|
return new ViewResult(
|
||||||
|
template: 'ml-dashboard',
|
||||||
|
metaData: new MetaData('ML Dashboard', 'Machine Learning Model Monitoring and Performance'),
|
||||||
|
data: $finalData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all models from registry (all names and all versions)
|
||||||
|
*/
|
||||||
|
private function getAllModels(): array
|
||||||
|
{
|
||||||
|
$modelNames = $this->registry->getAllModelNames();
|
||||||
|
|
||||||
|
$allModels = [];
|
||||||
|
foreach ($modelNames as $modelName) {
|
||||||
|
$versions = $this->registry->getAll($modelName);
|
||||||
|
$allModels = array_merge($allModels, $versions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $allModels;
|
||||||
|
}
|
||||||
|
}
|
||||||
253
src/Application/Admin/templates/ml-dashboard.view.php
Normal file
253
src/Application/Admin/templates/ml-dashboard.view.php
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<layout name="admin" />
|
||||||
|
|
||||||
|
<x-breadcrumbs items='[
|
||||||
|
{"label": "Admin", "url": "/admin"},
|
||||||
|
{"label": "ML Dashboard", "url": "/admin/ml/dashboard"}
|
||||||
|
]' />
|
||||||
|
|
||||||
|
<div class="admin-page">
|
||||||
|
<div class="admin-page__header">
|
||||||
|
<div class="admin-page__header-content">
|
||||||
|
<h1 class="admin-page__title">{{ $page_title }}</h1>
|
||||||
|
<p class="admin-page__subtitle">Monitor machine learning model performance and health metrics</p>
|
||||||
|
</div>
|
||||||
|
<div class="admin-page__actions">
|
||||||
|
<a href="{{ $api_dashboard_url }}" class="admin-button admin-button--secondary" target="_blank">
|
||||||
|
<svg class="admin-icon" width="16" height="16" fill="currentColor">
|
||||||
|
<path d="M8 2a6 6 0 100 12A6 6 0 008 2zm0 10a4 4 0 110-8 4 4 0 010 8z"/>
|
||||||
|
</svg>
|
||||||
|
View API
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="admin-grid admin-grid--3-col">
|
||||||
|
<!-- System Health Card -->
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="admin-card__header">
|
||||||
|
<h3 class="admin-card__title">System Health</h3>
|
||||||
|
</div>
|
||||||
|
<div class="admin-card__content">
|
||||||
|
<div class="admin-stat-list">
|
||||||
|
<div class="admin-stat-item">
|
||||||
|
<span class="admin-stat-item__label">Overall Status</span>
|
||||||
|
<span class="admin-stat-item__value">
|
||||||
|
<span class="admin-badge admin-badge--{{ $overall_badge }}">{{ $overall_status }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-item">
|
||||||
|
<span class="admin-stat-item__label">Health Percentage</span>
|
||||||
|
<span class="admin-stat-item__value">{{ $health_percentage }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-item">
|
||||||
|
<span class="admin-stat-item__label">Average Accuracy</span>
|
||||||
|
<span class="admin-stat-item__value">{{ $average_accuracy }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-item">
|
||||||
|
<span class="admin-stat-item__label">Time Window</span>
|
||||||
|
<span class="admin-stat-item__value">{{ $time_window_hours }} hours</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model Statistics Card -->
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="admin-card__header">
|
||||||
|
<h3 class="admin-card__title">Model Statistics</h3>
|
||||||
|
</div>
|
||||||
|
<div class="admin-card__content">
|
||||||
|
<div class="admin-stat-list">
|
||||||
|
<div class="admin-stat-item">
|
||||||
|
<span class="admin-stat-item__label">Total Models</span>
|
||||||
|
<span class="admin-stat-item__value">{{ $total_models }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-item">
|
||||||
|
<span class="admin-stat-item__label">Healthy</span>
|
||||||
|
<span class="admin-stat-item__value">
|
||||||
|
<span class="admin-badge admin-badge--success">{{ $healthy_models }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-item">
|
||||||
|
<span class="admin-stat-item__label">Degraded</span>
|
||||||
|
<span class="admin-stat-item__value">
|
||||||
|
<span class="admin-badge admin-badge--warning">{{ $degraded_models }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-item">
|
||||||
|
<span class="admin-stat-item__label">Critical</span>
|
||||||
|
<span class="admin-stat-item__value">
|
||||||
|
<span class="admin-badge admin-badge--danger">{{ $critical_models }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Performance Metrics Card -->
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="admin-card__header">
|
||||||
|
<h3 class="admin-card__title">Performance Metrics</h3>
|
||||||
|
</div>
|
||||||
|
<div class="admin-card__content">
|
||||||
|
<div class="admin-stat-list">
|
||||||
|
<div class="admin-stat-item">
|
||||||
|
<span class="admin-stat-item__label">Total Predictions</span>
|
||||||
|
<span class="admin-stat-item__value">{{ $total_predictions }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-item">
|
||||||
|
<span class="admin-stat-item__label">Supervised Models</span>
|
||||||
|
<span class="admin-stat-item__value">{{ $supervised_count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-item">
|
||||||
|
<span class="admin-stat-item__label">Unsupervised Models</span>
|
||||||
|
<span class="admin-stat-item__value">{{ $unsupervised_count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-item">
|
||||||
|
<span class="admin-stat-item__label">Reinforcement Models</span>
|
||||||
|
<span class="admin-stat-item__value">{{ $reinforcement_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Degradation Alerts Section -->
|
||||||
|
<div class="admin-card" if="{{ $has_alerts }}">
|
||||||
|
<div class="admin-card__header">
|
||||||
|
<h3 class="admin-card__title">
|
||||||
|
Degradation Alerts
|
||||||
|
<span class="admin-badge admin-badge--danger">{{ $alert_count }}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="admin-card__content">
|
||||||
|
<div class="admin-table-container">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Model</th>
|
||||||
|
<th>Version</th>
|
||||||
|
<th>Current Accuracy</th>
|
||||||
|
<th>Threshold</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Recommendation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr foreach="$alerts as $alert">
|
||||||
|
<td>
|
||||||
|
<strong>{{ $alert['model_name'] }}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>{{ $alert['version'] }}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="admin-badge admin-badge--{{ $alert['severity_badge'] }}">
|
||||||
|
{{ $alert['current_accuracy'] }}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ $alert['threshold'] }}%</td>
|
||||||
|
<td>
|
||||||
|
<span class="admin-badge admin-badge--{{ $alert['severity_badge'] }}">
|
||||||
|
{{ $alert['severity'] }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ $alert['recommendation'] }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Models Overview Section -->
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="admin-card__header">
|
||||||
|
<h3 class="admin-card__title">Models Overview</h3>
|
||||||
|
</div>
|
||||||
|
<div class="admin-card__content">
|
||||||
|
<div class="admin-table-container">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Model Name</th>
|
||||||
|
<th>Version</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Accuracy</th>
|
||||||
|
<th>Precision</th>
|
||||||
|
<th>Recall</th>
|
||||||
|
<th>F1 Score</th>
|
||||||
|
<th>Predictions</th>
|
||||||
|
<th>Avg Confidence</th>
|
||||||
|
<th>Threshold</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr foreach="$models as $model">
|
||||||
|
<td>
|
||||||
|
<strong>{{ $model['model_name'] }}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>{{ $model['version'] }}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="admin-badge admin-badge--info">
|
||||||
|
{{ $model['type'] }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ $model['accuracy'] }}%</td>
|
||||||
|
<td>
|
||||||
|
<span if="!{{ $model['precision'] }}">-</span>
|
||||||
|
<span if="{{ $model['precision'] }}">{{ $model['precision'] }}%</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span if="!{{ $model['recall'] }}">-</span>
|
||||||
|
<span if="{{ $model['recall'] }}">{{ $model['recall'] }}%</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span if="!{{ $model['f1_score'] }}">-</span>
|
||||||
|
<span if="{{ $model['f1_score'] }}">{{ $model['f1_score'] }}%</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ $model['total_predictions'] }}</td>
|
||||||
|
<td>
|
||||||
|
<span if="!{{ $model['average_confidence'] }}">-</span>
|
||||||
|
<span if="{{ $model['average_confidence'] }}">{{ $model['average_confidence'] }}%</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ $model['threshold'] }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="admin-badge admin-badge--{{ $model['status_badge'] }}">
|
||||||
|
{{ $model['status'] }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Information Card -->
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="admin-card__header">
|
||||||
|
<h3 class="admin-card__title">API Endpoints</h3>
|
||||||
|
</div>
|
||||||
|
<div class="admin-card__content">
|
||||||
|
<div class="admin-stat-list">
|
||||||
|
<div class="admin-stat-item">
|
||||||
|
<span class="admin-stat-item__label">Dashboard Data</span>
|
||||||
|
<span class="admin-stat-item__value">
|
||||||
|
<code>GET {{ $api_dashboard_url }}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-item">
|
||||||
|
<span class="admin-stat-item__label">Health Check</span>
|
||||||
|
<span class="admin-stat-item__value">
|
||||||
|
<code>GET {{ $api_health_url }}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
455
src/Application/Api/MachineLearning/MLABTestingController.php
Normal file
455
src/Application/Api/MachineLearning/MLABTestingController.php
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Application\Api\MachineLearning;
|
||||||
|
|
||||||
|
use App\Framework\Attributes\Route;
|
||||||
|
use App\Framework\Core\ValueObjects\Version;
|
||||||
|
use App\Framework\Http\HttpRequest;
|
||||||
|
use App\Framework\Http\Method;
|
||||||
|
use App\Framework\Http\Status;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ABTestingService;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ModelRegistry;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ValueObjects\ABTestConfig;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiEndpoint;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiParameter;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiRequestBody;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiResponse;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiSecurity;
|
||||||
|
use App\Framework\Router\Result\JsonResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ML A/B Testing API Controller
|
||||||
|
*
|
||||||
|
* RESTful API endpoints for A/B testing machine learning models:
|
||||||
|
* - Start A/B tests
|
||||||
|
* - Get test results
|
||||||
|
* - Generate rollout plans
|
||||||
|
* - Calculate sample sizes
|
||||||
|
*/
|
||||||
|
#[ApiSecurity('bearerAuth')]
|
||||||
|
final readonly class MLABTestingController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ABTestingService $abTesting,
|
||||||
|
private ModelRegistry $registry
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/ab-test', method: Method::POST)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Start A/B test',
|
||||||
|
description: 'Create and start an A/B test comparing two model versions',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiRequestBody(
|
||||||
|
description: 'A/B test configuration',
|
||||||
|
required: true,
|
||||||
|
example: [
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'version_a' => '1.0.0',
|
||||||
|
'version_b' => '2.0.0',
|
||||||
|
'traffic_split_a' => 0.5,
|
||||||
|
'primary_metric' => 'accuracy',
|
||||||
|
'minimum_improvement' => 0.05,
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 201,
|
||||||
|
description: 'A/B test created successfully',
|
||||||
|
example: [
|
||||||
|
'test_id' => 'test_123',
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'version_a' => '1.0.0',
|
||||||
|
'version_b' => '2.0.0',
|
||||||
|
'traffic_split' => [
|
||||||
|
'version_a' => 0.5,
|
||||||
|
'version_b' => 0.5,
|
||||||
|
],
|
||||||
|
'status' => 'running',
|
||||||
|
'created_at' => '2024-01-01T00:00:00Z',
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 400,
|
||||||
|
description: 'Invalid test configuration',
|
||||||
|
)]
|
||||||
|
public function startTest(HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$data = $request->parsedBody->toArray();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!isset($data['model_name'], $data['version_a'], $data['version_b'])) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Missing required fields',
|
||||||
|
'required' => ['model_name', 'version_a', 'version_b'],
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse versions
|
||||||
|
$versionA = Version::fromString($data['version_a']);
|
||||||
|
$versionB = Version::fromString($data['version_b']);
|
||||||
|
|
||||||
|
// Verify models exist
|
||||||
|
$metadataA = $this->registry->get($data['model_name'], $versionA);
|
||||||
|
$metadataB = $this->registry->get($data['model_name'], $versionB);
|
||||||
|
|
||||||
|
if ($metadataA === null) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Version A not found',
|
||||||
|
'model_name' => $data['model_name'],
|
||||||
|
'version' => $data['version_a'],
|
||||||
|
], Status::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($metadataB === null) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Version B not found',
|
||||||
|
'model_name' => $data['model_name'],
|
||||||
|
'version' => $data['version_b'],
|
||||||
|
], Status::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create A/B test config
|
||||||
|
$config = new ABTestConfig(
|
||||||
|
modelName: $data['model_name'],
|
||||||
|
versionA: $versionA,
|
||||||
|
versionB: $versionB,
|
||||||
|
trafficSplitA: (float) ($data['traffic_split_a'] ?? 0.5),
|
||||||
|
primaryMetric: $data['primary_metric'] ?? 'accuracy',
|
||||||
|
minimumImprovement: (float) ($data['minimum_improvement'] ?? 0.05),
|
||||||
|
significanceLevel: (float) ($data['significance_level'] ?? 0.05)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate test ID (in production, store in database)
|
||||||
|
$testId = 'test_' . bin2hex(random_bytes(8));
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'test_id' => $testId,
|
||||||
|
'model_name' => $config->modelName,
|
||||||
|
'version_a' => $config->versionA->toString(),
|
||||||
|
'version_b' => $config->versionB->toString(),
|
||||||
|
'traffic_split' => [
|
||||||
|
'version_a' => $config->trafficSplitA,
|
||||||
|
'version_b' => 1.0 - $config->trafficSplitA,
|
||||||
|
],
|
||||||
|
'primary_metric' => $config->primaryMetric,
|
||||||
|
'minimum_improvement' => $config->minimumImprovement,
|
||||||
|
'status' => 'running',
|
||||||
|
'description' => $config->getDescription(),
|
||||||
|
'created_at' => date('c'),
|
||||||
|
], Status::CREATED);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Invalid test configuration',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/ab-test/compare', method: Method::POST)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Compare model versions',
|
||||||
|
description: 'Compare performance of two model versions and get winner recommendation',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiRequestBody(
|
||||||
|
description: 'Model comparison configuration',
|
||||||
|
required: true,
|
||||||
|
example: [
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'version_a' => '1.0.0',
|
||||||
|
'version_b' => '2.0.0',
|
||||||
|
'primary_metric' => 'f1_score',
|
||||||
|
'minimum_improvement' => 0.05,
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Comparison completed successfully',
|
||||||
|
example: [
|
||||||
|
'winner' => 'B',
|
||||||
|
'statistically_significant' => true,
|
||||||
|
'metrics_difference' => [
|
||||||
|
'accuracy' => 0.07,
|
||||||
|
'f1_score' => 0.08,
|
||||||
|
],
|
||||||
|
'primary_metric_improvement' => 8.5,
|
||||||
|
'recommendation' => 'Version B wins with 8.5% improvement - deploy new version',
|
||||||
|
'summary' => 'Version B significantly outperforms Version A',
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 404,
|
||||||
|
description: 'Model version not found',
|
||||||
|
)]
|
||||||
|
public function compareVersions(HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$data = $request->parsedBody->toArray();
|
||||||
|
|
||||||
|
if (!isset($data['model_name'], $data['version_a'], $data['version_b'])) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Missing required fields',
|
||||||
|
'required' => ['model_name', 'version_a', 'version_b'],
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$versionA = Version::fromString($data['version_a']);
|
||||||
|
$versionB = Version::fromString($data['version_b']);
|
||||||
|
|
||||||
|
$config = new ABTestConfig(
|
||||||
|
modelName: $data['model_name'],
|
||||||
|
versionA: $versionA,
|
||||||
|
versionB: $versionB,
|
||||||
|
trafficSplitA: 0.5,
|
||||||
|
primaryMetric: $data['primary_metric'] ?? 'accuracy',
|
||||||
|
minimumImprovement: (float) ($data['minimum_improvement'] ?? 0.05)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run comparison
|
||||||
|
$result = $this->abTesting->runTest($config);
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'winner' => $result->winner,
|
||||||
|
'statistically_significant' => $result->isStatisticallySignificant,
|
||||||
|
'metrics_difference' => $result->metricsDifference,
|
||||||
|
'primary_metric_improvement' => $result->getPrimaryMetricImprovementPercent(),
|
||||||
|
'recommendation' => $result->recommendation,
|
||||||
|
'summary' => $result->getSummary(),
|
||||||
|
'should_deploy_version_b' => $result->shouldDeployVersionB(),
|
||||||
|
'is_inconclusive' => $result->isInconclusive(),
|
||||||
|
]);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Invalid comparison parameters',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/ab-test/rollout-plan', method: Method::POST)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Generate rollout plan',
|
||||||
|
description: 'Generate a gradual rollout plan for deploying a new model version',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiRequestBody(
|
||||||
|
description: 'Rollout configuration',
|
||||||
|
required: true,
|
||||||
|
example: [
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'current_version' => '1.0.0',
|
||||||
|
'new_version' => '2.0.0',
|
||||||
|
'steps' => 5,
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Rollout plan generated successfully',
|
||||||
|
example: [
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'current_version' => '1.0.0',
|
||||||
|
'new_version' => '2.0.0',
|
||||||
|
'rollout_stages' => [
|
||||||
|
[
|
||||||
|
'stage' => 1,
|
||||||
|
'current_version_traffic' => 80,
|
||||||
|
'new_version_traffic' => 20,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'stage' => 2,
|
||||||
|
'current_version_traffic' => 60,
|
||||||
|
'new_version_traffic' => 40,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
public function generateRolloutPlan(HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$data = $request->parsedBody->toArray();
|
||||||
|
|
||||||
|
if (!isset($data['model_name'], $data['current_version'], $data['new_version'])) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Missing required fields',
|
||||||
|
'required' => ['model_name', 'current_version', 'new_version'],
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$steps = (int) ($data['steps'] ?? 5);
|
||||||
|
|
||||||
|
if ($steps < 2 || $steps > 10) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Steps must be between 2 and 10',
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate rollout plan
|
||||||
|
$plan = $this->abTesting->generateRolloutPlan($steps);
|
||||||
|
|
||||||
|
// Format response
|
||||||
|
$stages = [];
|
||||||
|
foreach ($plan as $stage => $newVersionTraffic) {
|
||||||
|
$stages[] = [
|
||||||
|
'stage' => $stage,
|
||||||
|
'current_version_traffic' => (int) ((1.0 - $newVersionTraffic) * 100),
|
||||||
|
'new_version_traffic' => (int) ($newVersionTraffic * 100),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'model_name' => $data['model_name'],
|
||||||
|
'current_version' => $data['current_version'],
|
||||||
|
'new_version' => $data['new_version'],
|
||||||
|
'total_stages' => $steps,
|
||||||
|
'rollout_stages' => $stages,
|
||||||
|
'recommendation' => 'Monitor performance at each stage before proceeding to next',
|
||||||
|
]);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Invalid rollout configuration',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/ab-test/sample-size', method: Method::GET)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Calculate required sample size',
|
||||||
|
description: 'Calculate the required sample size for statistically significant A/B test',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'confidence_level',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Confidence level (0.90, 0.95, 0.99)',
|
||||||
|
required: false,
|
||||||
|
type: 'number',
|
||||||
|
example: 0.95,
|
||||||
|
)]
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'margin_of_error',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Margin of error (typically 0.01-0.10)',
|
||||||
|
required: false,
|
||||||
|
type: 'number',
|
||||||
|
example: 0.05,
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Sample size calculated successfully',
|
||||||
|
example: [
|
||||||
|
'required_samples_per_version' => 385,
|
||||||
|
'total_samples_needed' => 770,
|
||||||
|
'confidence_level' => 0.95,
|
||||||
|
'margin_of_error' => 0.05,
|
||||||
|
'recommendation' => 'Collect at least 385 predictions per version',
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
public function calculateSampleSize(HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
$confidenceLevel = (float) ($request->queryParameters['confidence_level'] ?? 0.95);
|
||||||
|
$marginOfError = (float) ($request->queryParameters['margin_of_error'] ?? 0.05);
|
||||||
|
|
||||||
|
// Validate parameters
|
||||||
|
if ($confidenceLevel < 0.5 || $confidenceLevel > 0.99) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Confidence level must be between 0.5 and 0.99',
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($marginOfError < 0.01 || $marginOfError > 0.20) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Margin of error must be between 0.01 and 0.20',
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate sample size
|
||||||
|
$samplesPerVersion = $this->abTesting->calculateRequiredSampleSize(
|
||||||
|
$confidenceLevel,
|
||||||
|
$marginOfError
|
||||||
|
);
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'required_samples_per_version' => $samplesPerVersion,
|
||||||
|
'total_samples_needed' => $samplesPerVersion * 2,
|
||||||
|
'confidence_level' => $confidenceLevel,
|
||||||
|
'margin_of_error' => $marginOfError,
|
||||||
|
'confidence_level_percent' => ($confidenceLevel * 100) . '%',
|
||||||
|
'margin_of_error_percent' => ($marginOfError * 100) . '%',
|
||||||
|
'recommendation' => "Collect at least {$samplesPerVersion} predictions per version for statistically significant results",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/ab-test/select-version', method: Method::POST)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Select model version for traffic routing',
|
||||||
|
description: 'Randomly select a model version based on A/B test traffic split configuration',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiRequestBody(
|
||||||
|
description: 'Traffic routing configuration',
|
||||||
|
required: true,
|
||||||
|
example: [
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'version_a' => '1.0.0',
|
||||||
|
'version_b' => '2.0.0',
|
||||||
|
'traffic_split_a' => 0.8,
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Version selected successfully',
|
||||||
|
example: [
|
||||||
|
'selected_version' => '2.0.0',
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'traffic_split' => [
|
||||||
|
'version_a' => 0.8,
|
||||||
|
'version_b' => 0.2,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
public function selectVersion(HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$data = $request->parsedBody->toArray();
|
||||||
|
|
||||||
|
if (!isset($data['model_name'], $data['version_a'], $data['version_b'])) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Missing required fields',
|
||||||
|
'required' => ['model_name', 'version_a', 'version_b'],
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$versionA = Version::fromString($data['version_a']);
|
||||||
|
$versionB = Version::fromString($data['version_b']);
|
||||||
|
|
||||||
|
$config = new ABTestConfig(
|
||||||
|
modelName: $data['model_name'],
|
||||||
|
versionA: $versionA,
|
||||||
|
versionB: $versionB,
|
||||||
|
trafficSplitA: (float) ($data['traffic_split_a'] ?? 0.5),
|
||||||
|
primaryMetric: 'accuracy'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select version based on traffic split
|
||||||
|
$selectedVersion = $this->abTesting->selectVersion($config);
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'selected_version' => $selectedVersion->toString(),
|
||||||
|
'model_name' => $config->modelName,
|
||||||
|
'traffic_split' => [
|
||||||
|
'version_a' => $config->trafficSplitA,
|
||||||
|
'version_b' => 1.0 - $config->trafficSplitA,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Invalid routing configuration',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
386
src/Application/Api/MachineLearning/MLAutoTuningController.php
Normal file
386
src/Application/Api/MachineLearning/MLAutoTuningController.php
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Application\Api\MachineLearning;
|
||||||
|
|
||||||
|
use App\Framework\Attributes\Route;
|
||||||
|
use App\Framework\Core\ValueObjects\Version;
|
||||||
|
use App\Framework\Http\HttpRequest;
|
||||||
|
use App\Framework\Http\Method;
|
||||||
|
use App\Framework\Http\Status;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\AutoTuningEngine;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ModelRegistry;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiEndpoint;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiParameter;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiRequestBody;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiResponse;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiSecurity;
|
||||||
|
use App\Framework\Router\Result\JsonResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ML Auto-Tuning API Controller
|
||||||
|
*
|
||||||
|
* RESTful API endpoints for automatic ML model optimization:
|
||||||
|
* - Threshold optimization
|
||||||
|
* - Adaptive threshold adjustment
|
||||||
|
* - Precision-recall trade-off optimization
|
||||||
|
* - Hyperparameter tuning
|
||||||
|
*/
|
||||||
|
#[ApiSecurity('bearerAuth')]
|
||||||
|
final readonly class MLAutoTuningController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private AutoTuningEngine $autoTuning,
|
||||||
|
private ModelRegistry $registry
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/optimize/threshold', method: Method::POST)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Optimize model threshold',
|
||||||
|
description: 'Find optimal threshold using grid search to maximize specified metric',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiRequestBody(
|
||||||
|
description: 'Threshold optimization configuration',
|
||||||
|
required: true,
|
||||||
|
example: [
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'metric_to_optimize' => 'f1_score',
|
||||||
|
'threshold_range' => [0.5, 0.9],
|
||||||
|
'step' => 0.05,
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Threshold optimization completed',
|
||||||
|
example: [
|
||||||
|
'optimal_threshold' => 0.75,
|
||||||
|
'optimal_metric_value' => 0.92,
|
||||||
|
'current_threshold' => 0.7,
|
||||||
|
'current_metric_value' => 0.89,
|
||||||
|
'improvement_percent' => 3.37,
|
||||||
|
'recommendation' => 'MODERATE IMPROVEMENT: Consider updating threshold from 0.70 to 0.75 (3.4% gain)',
|
||||||
|
'tested_thresholds' => 9,
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 404,
|
||||||
|
description: 'Model not found',
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 400,
|
||||||
|
description: 'Insufficient data for optimization (requires minimum 100 predictions)',
|
||||||
|
)]
|
||||||
|
public function optimizeThreshold(HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$data = $request->parsedBody->toArray();
|
||||||
|
|
||||||
|
if (!isset($data['model_name'], $data['version'])) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Missing required fields',
|
||||||
|
'required' => ['model_name', 'version'],
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = Version::fromString($data['version']);
|
||||||
|
|
||||||
|
// Verify model exists
|
||||||
|
$metadata = $this->registry->get($data['model_name'], $version);
|
||||||
|
if ($metadata === null) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Model not found',
|
||||||
|
'model_name' => $data['model_name'],
|
||||||
|
'version' => $data['version'],
|
||||||
|
], Status::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimize threshold
|
||||||
|
$result = $this->autoTuning->optimizeThreshold(
|
||||||
|
modelName: $data['model_name'],
|
||||||
|
version: $version,
|
||||||
|
metricToOptimize: $data['metric_to_optimize'] ?? 'f1_score',
|
||||||
|
thresholdRange: $data['threshold_range'] ?? [0.5, 0.9],
|
||||||
|
step: (float) ($data['step'] ?? 0.05)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'optimal_threshold' => $result['optimal_threshold'],
|
||||||
|
'optimal_metric_value' => $result['optimal_metric_value'],
|
||||||
|
'current_threshold' => $result['current_threshold'],
|
||||||
|
'current_metric_value' => $result['current_metric_value'],
|
||||||
|
'improvement_percent' => $result['improvement_percent'],
|
||||||
|
'metric_optimized' => $result['metric_optimized'],
|
||||||
|
'recommendation' => $result['recommendation'],
|
||||||
|
'tested_thresholds' => count($result['all_results']),
|
||||||
|
'all_results' => $result['all_results'],
|
||||||
|
]);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Optimization failed',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Invalid optimization parameters',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/optimize/adaptive-threshold', method: Method::POST)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Adaptive threshold adjustment',
|
||||||
|
description: 'Dynamically adjust threshold based on false positive/negative rates',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiRequestBody(
|
||||||
|
description: 'Model identification',
|
||||||
|
required: true,
|
||||||
|
example: [
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Adaptive adjustment calculated',
|
||||||
|
example: [
|
||||||
|
'recommended_threshold' => 0.75,
|
||||||
|
'current_threshold' => 0.7,
|
||||||
|
'adjustment_reason' => 'High false positive rate - increasing threshold to reduce false alarms',
|
||||||
|
'current_fp_rate' => 0.12,
|
||||||
|
'current_fn_rate' => 0.05,
|
||||||
|
'expected_improvement' => [
|
||||||
|
'accuracy' => 0.03,
|
||||||
|
'precision' => 0.05,
|
||||||
|
'recall' => -0.02,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 404,
|
||||||
|
description: 'Model not found',
|
||||||
|
)]
|
||||||
|
public function adaptiveThresholdAdjustment(HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$data = $request->parsedBody->toArray();
|
||||||
|
|
||||||
|
if (!isset($data['model_name'], $data['version'])) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Missing required fields',
|
||||||
|
'required' => ['model_name', 'version'],
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = Version::fromString($data['version']);
|
||||||
|
|
||||||
|
// Verify model exists
|
||||||
|
$metadata = $this->registry->get($data['model_name'], $version);
|
||||||
|
if ($metadata === null) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Model not found',
|
||||||
|
'model_name' => $data['model_name'],
|
||||||
|
'version' => $data['version'],
|
||||||
|
], Status::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate adaptive adjustment
|
||||||
|
$result = $this->autoTuning->adaptiveThresholdAdjustment(
|
||||||
|
$data['model_name'],
|
||||||
|
$version
|
||||||
|
);
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'recommended_threshold' => $result['recommended_threshold'],
|
||||||
|
'current_threshold' => $result['current_threshold'],
|
||||||
|
'adjustment_reason' => $result['adjustment_reason'],
|
||||||
|
'current_fp_rate' => $result['current_fp_rate'],
|
||||||
|
'current_fn_rate' => $result['current_fn_rate'],
|
||||||
|
'expected_improvement' => $result['expected_improvement'],
|
||||||
|
]);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Invalid adjustment parameters',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/optimize/precision-recall', method: Method::POST)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Optimize precision-recall trade-off',
|
||||||
|
description: 'Find threshold that achieves target precision while maximizing recall',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiRequestBody(
|
||||||
|
description: 'Precision-recall optimization configuration',
|
||||||
|
required: true,
|
||||||
|
example: [
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'target_precision' => 0.95,
|
||||||
|
'threshold_range' => [0.5, 0.99],
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Precision-recall optimization completed',
|
||||||
|
example: [
|
||||||
|
'optimal_threshold' => 0.82,
|
||||||
|
'achieved_precision' => 0.95,
|
||||||
|
'achieved_recall' => 0.78,
|
||||||
|
'f1_score' => 0.86,
|
||||||
|
'target_precision' => 0.95,
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 404,
|
||||||
|
description: 'Model not found',
|
||||||
|
)]
|
||||||
|
public function optimizePrecisionRecall(HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$data = $request->parsedBody->toArray();
|
||||||
|
|
||||||
|
if (!isset($data['model_name'], $data['version'])) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Missing required fields',
|
||||||
|
'required' => ['model_name', 'version'],
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = Version::fromString($data['version']);
|
||||||
|
|
||||||
|
// Verify model exists
|
||||||
|
$metadata = $this->registry->get($data['model_name'], $version);
|
||||||
|
if ($metadata === null) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Model not found',
|
||||||
|
'model_name' => $data['model_name'],
|
||||||
|
'version' => $data['version'],
|
||||||
|
], Status::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimize precision-recall trade-off
|
||||||
|
$result = $this->autoTuning->optimizePrecisionRecallTradeoff(
|
||||||
|
modelName: $data['model_name'],
|
||||||
|
version: $version,
|
||||||
|
targetPrecision: (float) ($data['target_precision'] ?? 0.95),
|
||||||
|
thresholdRange: $data['threshold_range'] ?? [0.5, 0.99]
|
||||||
|
);
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'optimal_threshold' => $result['optimal_threshold'],
|
||||||
|
'achieved_precision' => $result['achieved_precision'],
|
||||||
|
'achieved_recall' => $result['achieved_recall'],
|
||||||
|
'f1_score' => $result['f1_score'],
|
||||||
|
'target_precision' => (float) ($data['target_precision'] ?? 0.95),
|
||||||
|
'recommendation' => sprintf(
|
||||||
|
'Use threshold %.2f to achieve %.1f%% precision with %.1f%% recall',
|
||||||
|
$result['optimal_threshold'],
|
||||||
|
$result['achieved_precision'] * 100,
|
||||||
|
$result['achieved_recall'] * 100
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Invalid optimization parameters',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/optimize/apply-threshold', method: Method::POST)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Apply optimized threshold',
|
||||||
|
description: 'Update model configuration with optimized threshold',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiRequestBody(
|
||||||
|
description: 'Threshold application configuration',
|
||||||
|
required: true,
|
||||||
|
example: [
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'new_threshold' => 0.75,
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Threshold applied successfully',
|
||||||
|
example: [
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'old_threshold' => 0.7,
|
||||||
|
'new_threshold' => 0.75,
|
||||||
|
'message' => 'Threshold updated successfully',
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 404,
|
||||||
|
description: 'Model not found',
|
||||||
|
)]
|
||||||
|
public function applyThreshold(HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$data = $request->parsedBody->toArray();
|
||||||
|
|
||||||
|
if (!isset($data['model_name'], $data['version'], $data['new_threshold'])) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Missing required fields',
|
||||||
|
'required' => ['model_name', 'version', 'new_threshold'],
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = Version::fromString($data['version']);
|
||||||
|
$newThreshold = (float) $data['new_threshold'];
|
||||||
|
|
||||||
|
// Validate threshold range
|
||||||
|
if ($newThreshold < 0.0 || $newThreshold > 1.0) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Threshold must be between 0.0 and 1.0',
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current model
|
||||||
|
$metadata = $this->registry->get($data['model_name'], $version);
|
||||||
|
if ($metadata === null) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Model not found',
|
||||||
|
'model_name' => $data['model_name'],
|
||||||
|
'version' => $data['version'],
|
||||||
|
], Status::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldThreshold = $metadata->configuration['threshold'] ?? null;
|
||||||
|
|
||||||
|
// Update configuration
|
||||||
|
$updatedMetadata = $metadata->withConfiguration([
|
||||||
|
...$metadata->configuration,
|
||||||
|
'threshold' => $newThreshold,
|
||||||
|
'threshold_updated_at' => date('c'),
|
||||||
|
'threshold_update_reason' => $data['reason'] ?? 'Manual optimization',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->registry->update($updatedMetadata);
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'model_name' => $data['model_name'],
|
||||||
|
'version' => $version->toString(),
|
||||||
|
'old_threshold' => $oldThreshold,
|
||||||
|
'new_threshold' => $newThreshold,
|
||||||
|
'message' => 'Threshold updated successfully',
|
||||||
|
'updated_at' => date('c'),
|
||||||
|
]);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Invalid threshold update',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
472
src/Application/Api/MachineLearning/MLDashboardController.php
Normal file
472
src/Application/Api/MachineLearning/MLDashboardController.php
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Application\Api\MachineLearning;
|
||||||
|
|
||||||
|
use App\Framework\Attributes\Route;
|
||||||
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
use App\Framework\Core\ValueObjects\Timestamp;
|
||||||
|
use App\Framework\Http\HttpRequest;
|
||||||
|
use App\Framework\Http\Method;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ModelPerformanceMonitor;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ModelRegistry;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiEndpoint;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiParameter;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiResponse;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiSecurity;
|
||||||
|
use App\Framework\Router\Result\JsonResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ML Dashboard API Controller
|
||||||
|
*
|
||||||
|
* RESTful API endpoints for ML monitoring dashboard:
|
||||||
|
* - Performance overview
|
||||||
|
* - Degradation alerts
|
||||||
|
* - System health indicators
|
||||||
|
* - Comprehensive dashboard data export
|
||||||
|
*/
|
||||||
|
#[ApiSecurity('bearerAuth')]
|
||||||
|
final readonly class MLDashboardController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ModelRegistry $registry,
|
||||||
|
private ModelPerformanceMonitor $performanceMonitor
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all models from registry (all names and all versions)
|
||||||
|
*/
|
||||||
|
private function getAllModels(): array
|
||||||
|
{
|
||||||
|
$modelNames = $this->registry->getAllModelNames();
|
||||||
|
|
||||||
|
$allModels = [];
|
||||||
|
foreach ($modelNames as $modelName) {
|
||||||
|
$versions = $this->registry->getAll($modelName);
|
||||||
|
$allModels = array_merge($allModels, $versions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $allModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/dashboard', method: Method::GET)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Get complete dashboard data',
|
||||||
|
description: 'Retrieve comprehensive ML system dashboard data including performance, alerts, and health',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'timeWindow',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Time window in hours for metrics (default: 24)',
|
||||||
|
required: false,
|
||||||
|
type: 'integer',
|
||||||
|
example: 24,
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Dashboard data retrieved successfully',
|
||||||
|
example: [
|
||||||
|
'timestamp' => '2024-01-01T00:00:00Z',
|
||||||
|
'summary' => [
|
||||||
|
'total_models' => 5,
|
||||||
|
'healthy_models' => 4,
|
||||||
|
'degraded_models' => 1,
|
||||||
|
'total_predictions' => 10523,
|
||||||
|
'average_accuracy' => 0.91,
|
||||||
|
'overall_status' => 'healthy',
|
||||||
|
],
|
||||||
|
'models' => [
|
||||||
|
[
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'type' => 'supervised',
|
||||||
|
'accuracy' => 0.94,
|
||||||
|
'status' => 'healthy',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'alerts' => [],
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
public function getDashboardData(HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
$timeWindowHours = (int) ($request->queryParameters['timeWindow'] ?? 24);
|
||||||
|
$timeWindow = Duration::fromHours($timeWindowHours);
|
||||||
|
|
||||||
|
// Get all models
|
||||||
|
$allModels = $this->getAllModels();
|
||||||
|
|
||||||
|
// Collect performance overview
|
||||||
|
$performanceOverview = [];
|
||||||
|
$totalPredictions = 0;
|
||||||
|
$accuracySum = 0.0;
|
||||||
|
$healthyCount = 0;
|
||||||
|
$degradedCount = 0;
|
||||||
|
|
||||||
|
foreach ($allModels as $metadata) {
|
||||||
|
$metrics = $this->performanceMonitor->getCurrentMetrics(
|
||||||
|
$metadata->modelName,
|
||||||
|
$metadata->version,
|
||||||
|
$timeWindow
|
||||||
|
);
|
||||||
|
|
||||||
|
$isHealthy = $metrics['accuracy'] >= 0.85;
|
||||||
|
|
||||||
|
if ($isHealthy) {
|
||||||
|
$healthyCount++;
|
||||||
|
} else {
|
||||||
|
$degradedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$performanceOverview[] = [
|
||||||
|
'model_name' => $metadata->modelName,
|
||||||
|
'version' => $metadata->version->toString(),
|
||||||
|
'type' => $metadata->modelType->value,
|
||||||
|
'accuracy' => $metrics['accuracy'],
|
||||||
|
'precision' => $metrics['precision'] ?? null,
|
||||||
|
'recall' => $metrics['recall'] ?? null,
|
||||||
|
'f1_score' => $metrics['f1_score'] ?? null,
|
||||||
|
'total_predictions' => $metrics['total_predictions'],
|
||||||
|
'average_confidence' => $metrics['average_confidence'] ?? null,
|
||||||
|
'threshold' => $metadata->configuration['threshold'] ?? null,
|
||||||
|
'status' => $isHealthy ? 'healthy' : 'degraded',
|
||||||
|
];
|
||||||
|
|
||||||
|
$totalPredictions += $metrics['total_predictions'];
|
||||||
|
$accuracySum += $metrics['accuracy'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate degradation alerts
|
||||||
|
$degradationAlerts = [];
|
||||||
|
foreach ($performanceOverview as $model) {
|
||||||
|
if ($model['status'] === 'degraded') {
|
||||||
|
$degradationAlerts[] = [
|
||||||
|
'model_name' => $model['model_name'],
|
||||||
|
'version' => $model['version'],
|
||||||
|
'current_accuracy' => $model['accuracy'],
|
||||||
|
'threshold' => 0.85,
|
||||||
|
'severity' => $model['accuracy'] < 0.7 ? 'critical' : 'warning',
|
||||||
|
'recommendation' => 'Consider retraining or rolling back to previous version',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate health indicators
|
||||||
|
$modelCount = count($allModels);
|
||||||
|
$averageAccuracy = $modelCount > 0 ? $accuracySum / $modelCount : 0.0;
|
||||||
|
$overallStatus = $degradedCount === 0 ? 'healthy' : ($degradedCount > $modelCount / 2 ? 'critical' : 'warning');
|
||||||
|
|
||||||
|
// Build dashboard data
|
||||||
|
$dashboardData = [
|
||||||
|
'timestamp' => Timestamp::now()->format('Y-m-d\TH:i:s\Z'),
|
||||||
|
'time_window_hours' => $timeWindowHours,
|
||||||
|
'summary' => [
|
||||||
|
'total_models' => $modelCount,
|
||||||
|
'healthy_models' => $healthyCount,
|
||||||
|
'degraded_models' => $degradedCount,
|
||||||
|
'total_predictions' => $totalPredictions,
|
||||||
|
'average_accuracy' => round($averageAccuracy, 4),
|
||||||
|
'overall_status' => $overallStatus,
|
||||||
|
],
|
||||||
|
'models' => $performanceOverview,
|
||||||
|
'alerts' => $degradationAlerts,
|
||||||
|
];
|
||||||
|
|
||||||
|
return new JsonResult($dashboardData);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/dashboard/health', method: Method::GET)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Get system health indicators',
|
||||||
|
description: 'Retrieve ML system health status and key indicators',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Health indicators retrieved successfully',
|
||||||
|
example: [
|
||||||
|
'overall_status' => 'healthy',
|
||||||
|
'healthy_models' => 4,
|
||||||
|
'degraded_models' => 1,
|
||||||
|
'critical_models' => 0,
|
||||||
|
'total_models' => 5,
|
||||||
|
'health_percentage' => 80.0,
|
||||||
|
'average_accuracy' => 0.91,
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
public function getHealthIndicators(): JsonResult
|
||||||
|
{
|
||||||
|
$allModels = $this->getAllModels();
|
||||||
|
$timeWindow = Duration::fromHours(1);
|
||||||
|
|
||||||
|
$healthyCount = 0;
|
||||||
|
$degradedCount = 0;
|
||||||
|
$criticalCount = 0;
|
||||||
|
$accuracySum = 0.0;
|
||||||
|
|
||||||
|
foreach ($allModels as $metadata) {
|
||||||
|
$metrics = $this->performanceMonitor->getCurrentMetrics(
|
||||||
|
$metadata->modelName,
|
||||||
|
$metadata->version,
|
||||||
|
$timeWindow
|
||||||
|
);
|
||||||
|
|
||||||
|
$accuracy = $metrics['accuracy'];
|
||||||
|
$accuracySum += $accuracy;
|
||||||
|
|
||||||
|
if ($accuracy >= 0.85) {
|
||||||
|
$healthyCount++;
|
||||||
|
} elseif ($accuracy >= 0.7) {
|
||||||
|
$degradedCount++;
|
||||||
|
} else {
|
||||||
|
$criticalCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$modelCount = count($allModels);
|
||||||
|
$healthPercentage = $modelCount > 0 ? ($healthyCount / $modelCount) * 100 : 0.0;
|
||||||
|
$averageAccuracy = $modelCount > 0 ? $accuracySum / $modelCount : 0.0;
|
||||||
|
|
||||||
|
$overallStatus = match (true) {
|
||||||
|
$criticalCount > 0 => 'critical',
|
||||||
|
$degradedCount > $modelCount / 2 => 'warning',
|
||||||
|
$degradedCount > 0 => 'warning',
|
||||||
|
default => 'healthy'
|
||||||
|
};
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'overall_status' => $overallStatus,
|
||||||
|
'healthy_models' => $healthyCount,
|
||||||
|
'degraded_models' => $degradedCount,
|
||||||
|
'critical_models' => $criticalCount,
|
||||||
|
'total_models' => $modelCount,
|
||||||
|
'health_percentage' => round($healthPercentage, 2),
|
||||||
|
'average_accuracy' => round($averageAccuracy, 4),
|
||||||
|
'timestamp' => date('c'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/dashboard/alerts', method: Method::GET)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Get active alerts',
|
||||||
|
description: 'Retrieve all active degradation and performance alerts',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'severity',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Filter by severity (warning, critical)',
|
||||||
|
required: false,
|
||||||
|
type: 'string',
|
||||||
|
example: 'critical',
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Alerts retrieved successfully',
|
||||||
|
example: [
|
||||||
|
'alerts' => [
|
||||||
|
[
|
||||||
|
'model_name' => 'spam-classifier',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'severity' => 'warning',
|
||||||
|
'current_accuracy' => 0.78,
|
||||||
|
'threshold' => 0.85,
|
||||||
|
'recommendation' => 'Consider retraining or rolling back',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'total_alerts' => 1,
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
public function getAlerts(HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
$severityFilter = $request->queryParameters['severity'] ?? null;
|
||||||
|
$allModels = $this->getAllModels();
|
||||||
|
$timeWindow = Duration::fromHours(1);
|
||||||
|
|
||||||
|
$alerts = [];
|
||||||
|
|
||||||
|
foreach ($allModels as $metadata) {
|
||||||
|
$metrics = $this->performanceMonitor->getCurrentMetrics(
|
||||||
|
$metadata->modelName,
|
||||||
|
$metadata->version,
|
||||||
|
$timeWindow
|
||||||
|
);
|
||||||
|
|
||||||
|
$accuracy = $metrics['accuracy'];
|
||||||
|
|
||||||
|
if ($accuracy < 0.85) {
|
||||||
|
$severity = $accuracy < 0.7 ? 'critical' : 'warning';
|
||||||
|
|
||||||
|
// Apply severity filter if specified
|
||||||
|
if ($severityFilter !== null && $severity !== strtolower($severityFilter)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$alerts[] = [
|
||||||
|
'model_name' => $metadata->modelName,
|
||||||
|
'version' => $metadata->version->toString(),
|
||||||
|
'type' => $metadata->modelType->value,
|
||||||
|
'severity' => $severity,
|
||||||
|
'current_accuracy' => $accuracy,
|
||||||
|
'threshold' => 0.85,
|
||||||
|
'deviation' => round((0.85 - $accuracy) * 100, 2),
|
||||||
|
'total_predictions' => $metrics['total_predictions'],
|
||||||
|
'recommendation' => 'Consider retraining or rolling back to previous version',
|
||||||
|
'detected_at' => date('c'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'alerts' => $alerts,
|
||||||
|
'total_alerts' => count($alerts),
|
||||||
|
'severity_filter' => $severityFilter,
|
||||||
|
'timestamp' => date('c'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/dashboard/confusion-matrices', method: Method::GET)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Get confusion matrices',
|
||||||
|
description: 'Retrieve confusion matrices for all models with classification metrics',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Confusion matrices retrieved successfully',
|
||||||
|
example: [
|
||||||
|
'matrices' => [
|
||||||
|
[
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'confusion_matrix' => [
|
||||||
|
'true_positive' => 234,
|
||||||
|
'true_negative' => 145,
|
||||||
|
'false_positive' => 12,
|
||||||
|
'false_negative' => 9,
|
||||||
|
],
|
||||||
|
'fp_rate' => 0.03,
|
||||||
|
'fn_rate' => 0.023,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
public function getConfusionMatrices(): JsonResult
|
||||||
|
{
|
||||||
|
$allModels = $this->getAllModels();
|
||||||
|
$timeWindow = Duration::fromHours(24);
|
||||||
|
|
||||||
|
$matrices = [];
|
||||||
|
|
||||||
|
foreach ($allModels as $metadata) {
|
||||||
|
$metrics = $this->performanceMonitor->getCurrentMetrics(
|
||||||
|
$metadata->modelName,
|
||||||
|
$metadata->version,
|
||||||
|
$timeWindow
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isset($metrics['confusion_matrix'])) {
|
||||||
|
$cm = $metrics['confusion_matrix'];
|
||||||
|
$total = $metrics['total_predictions'];
|
||||||
|
|
||||||
|
$matrices[] = [
|
||||||
|
'model_name' => $metadata->modelName,
|
||||||
|
'version' => $metadata->version->toString(),
|
||||||
|
'type' => $metadata->modelType->value,
|
||||||
|
'confusion_matrix' => $cm,
|
||||||
|
'fp_rate' => $total > 0 ? round($cm['false_positive'] / $total, 4) : 0.0,
|
||||||
|
'fn_rate' => $total > 0 ? round($cm['false_negative'] / $total, 4) : 0.0,
|
||||||
|
'total_predictions' => $total,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'matrices' => $matrices,
|
||||||
|
'total_models' => count($matrices),
|
||||||
|
'timestamp' => date('c'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/dashboard/registry-summary', method: Method::GET)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Get registry summary',
|
||||||
|
description: 'Retrieve summary statistics about the model registry',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Registry summary retrieved successfully',
|
||||||
|
example: [
|
||||||
|
'total_models' => 5,
|
||||||
|
'by_type' => [
|
||||||
|
'supervised' => 3,
|
||||||
|
'unsupervised' => 2,
|
||||||
|
'reinforcement' => 0,
|
||||||
|
],
|
||||||
|
'total_versions' => 12,
|
||||||
|
'models' => [
|
||||||
|
[
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'version_count' => 3,
|
||||||
|
'latest_version' => '3.0.0',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
public function getRegistrySummary(): JsonResult
|
||||||
|
{
|
||||||
|
$allModels = $this->getAllModels();
|
||||||
|
|
||||||
|
// Count by type
|
||||||
|
$byType = [
|
||||||
|
'supervised' => 0,
|
||||||
|
'unsupervised' => 0,
|
||||||
|
'reinforcement' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Group by model name
|
||||||
|
$modelGroups = [];
|
||||||
|
|
||||||
|
foreach ($allModels as $metadata) {
|
||||||
|
$typeName = strtolower($metadata->modelType->value);
|
||||||
|
$byType[$typeName] = ($byType[$typeName] ?? 0) + 1;
|
||||||
|
|
||||||
|
$modelName = $metadata->modelName;
|
||||||
|
if (!isset($modelGroups[$modelName])) {
|
||||||
|
$modelGroups[$modelName] = [
|
||||||
|
'model_name' => $modelName,
|
||||||
|
'type' => $metadata->modelType->value,
|
||||||
|
'versions' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$modelGroups[$modelName]['versions'][] = $metadata->version->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate summary per model
|
||||||
|
$modelsSummary = [];
|
||||||
|
foreach ($modelGroups as $modelName => $group) {
|
||||||
|
// Sort versions
|
||||||
|
$versions = $group['versions'];
|
||||||
|
usort($versions, 'version_compare');
|
||||||
|
|
||||||
|
$modelsSummary[] = [
|
||||||
|
'model_name' => $modelName,
|
||||||
|
'type' => $group['type'],
|
||||||
|
'version_count' => count($versions),
|
||||||
|
'latest_version' => end($versions),
|
||||||
|
'oldest_version' => reset($versions),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'total_models' => count($modelGroups),
|
||||||
|
'by_type' => $byType,
|
||||||
|
'total_versions' => count($allModels),
|
||||||
|
'models' => $modelsSummary,
|
||||||
|
'timestamp' => date('c'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
478
src/Application/Api/MachineLearning/MLModelsController.php
Normal file
478
src/Application/Api/MachineLearning/MLModelsController.php
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Application\Api\MachineLearning;
|
||||||
|
|
||||||
|
use App\Framework\Attributes\Route;
|
||||||
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
use App\Framework\Core\ValueObjects\Timestamp;
|
||||||
|
use App\Framework\Core\ValueObjects\Version;
|
||||||
|
use App\Framework\Http\HttpRequest;
|
||||||
|
use App\Framework\Http\Method;
|
||||||
|
use App\Framework\Http\Status;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ModelPerformanceMonitor;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ModelRegistry;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ValueObjects\ModelMetadata;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ValueObjects\ModelType;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiEndpoint;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiParameter;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiRequestBody;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiResponse;
|
||||||
|
use App\Framework\OpenApi\Attributes\ApiSecurity;
|
||||||
|
use App\Framework\Router\Result\JsonResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ML Models Management API Controller
|
||||||
|
*
|
||||||
|
* RESTful API endpoints for managing machine learning models:
|
||||||
|
* - Model registration
|
||||||
|
* - Performance metrics retrieval
|
||||||
|
* - Model listing and versioning
|
||||||
|
*/
|
||||||
|
#[ApiSecurity('bearerAuth')]
|
||||||
|
final readonly class MLModelsController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ModelRegistry $registry,
|
||||||
|
private ModelPerformanceMonitor $performanceMonitor
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/models', method: Method::GET)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'List all ML models',
|
||||||
|
description: 'Retrieve a list of all registered machine learning models with their versions',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'type',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Filter by model type (supervised, unsupervised, reinforcement)',
|
||||||
|
required: false,
|
||||||
|
type: 'string',
|
||||||
|
example: 'supervised',
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'List of ML models retrieved successfully',
|
||||||
|
example: [
|
||||||
|
'models' => [
|
||||||
|
[
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'type' => 'supervised',
|
||||||
|
'versions' => [
|
||||||
|
[
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'created_at' => '2024-01-01T00:00:00Z',
|
||||||
|
'is_latest' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'total_models' => 5,
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
public function listModels(HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
$typeFilter = $request->queryParameters['type'] ?? null;
|
||||||
|
|
||||||
|
// Get all model names
|
||||||
|
$modelNames = $this->registry->getAllModelNames();
|
||||||
|
|
||||||
|
// Get all versions for each model
|
||||||
|
$allModels = [];
|
||||||
|
foreach ($modelNames as $modelName) {
|
||||||
|
$versions = $this->registry->getAll($modelName);
|
||||||
|
$allModels = array_merge($allModels, $versions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by type if specified
|
||||||
|
if ($typeFilter !== null) {
|
||||||
|
$allModels = array_filter($allModels, function (ModelMetadata $metadata) use ($typeFilter) {
|
||||||
|
return strtolower($metadata->modelType->value) === strtolower($typeFilter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by model name
|
||||||
|
$groupedModels = [];
|
||||||
|
foreach ($allModels as $metadata) {
|
||||||
|
$modelName = $metadata->modelName;
|
||||||
|
|
||||||
|
if (!isset($groupedModels[$modelName])) {
|
||||||
|
$groupedModels[$modelName] = [
|
||||||
|
'model_name' => $modelName,
|
||||||
|
'type' => $metadata->modelType->value,
|
||||||
|
'versions' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupedModels[$modelName]['versions'][] = [
|
||||||
|
'version' => $metadata->version->toString(),
|
||||||
|
'created_at' => $metadata->createdAt->format('Y-m-d\TH:i:s\Z'),
|
||||||
|
'configuration' => $metadata->configuration,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'models' => array_values($groupedModels),
|
||||||
|
'total_models' => count($groupedModels),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/models/{modelName}', method: Method::GET)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Get model details',
|
||||||
|
description: 'Retrieve detailed information about a specific ML model',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'modelName',
|
||||||
|
in: 'path',
|
||||||
|
description: 'Model identifier',
|
||||||
|
required: true,
|
||||||
|
type: 'string',
|
||||||
|
example: 'fraud-detector',
|
||||||
|
)]
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'version',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Specific version (optional, defaults to latest)',
|
||||||
|
required: false,
|
||||||
|
type: 'string',
|
||||||
|
example: '1.0.0',
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Model details retrieved successfully',
|
||||||
|
example: [
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'type' => 'supervised',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'configuration' => [
|
||||||
|
'threshold' => 0.7,
|
||||||
|
'algorithm' => 'random_forest',
|
||||||
|
],
|
||||||
|
'created_at' => '2024-01-01T00:00:00Z',
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 404,
|
||||||
|
description: 'Model not found',
|
||||||
|
)]
|
||||||
|
public function getModel(string $modelName, HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
$versionString = $request->queryParameters['version'] ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($versionString !== null) {
|
||||||
|
$version = Version::fromString($versionString);
|
||||||
|
$metadata = $this->registry->get($modelName, $version);
|
||||||
|
} else {
|
||||||
|
$metadata = $this->registry->getLatest($modelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($metadata === null) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Model not found',
|
||||||
|
'model_name' => $modelName,
|
||||||
|
], Status::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'model_name' => $metadata->modelName,
|
||||||
|
'type' => $metadata->modelType->value,
|
||||||
|
'version' => $metadata->version->toString(),
|
||||||
|
'configuration' => $metadata->configuration,
|
||||||
|
'performance_metrics' => $metadata->performanceMetrics,
|
||||||
|
'created_at' => $metadata->createdAt->format('Y-m-d\TH:i:s\Z'),
|
||||||
|
]);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Invalid version format',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/models/{modelName}/metrics', method: Method::GET)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Get model performance metrics',
|
||||||
|
description: 'Retrieve real-time performance metrics for a specific model',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'modelName',
|
||||||
|
in: 'path',
|
||||||
|
description: 'Model identifier',
|
||||||
|
required: true,
|
||||||
|
type: 'string',
|
||||||
|
example: 'fraud-detector',
|
||||||
|
)]
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'version',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Model version',
|
||||||
|
required: false,
|
||||||
|
type: 'string',
|
||||||
|
example: '1.0.0',
|
||||||
|
)]
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'timeWindow',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Time window in hours (default: 1)',
|
||||||
|
required: false,
|
||||||
|
type: 'integer',
|
||||||
|
example: 24,
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Performance metrics retrieved successfully',
|
||||||
|
example: [
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'time_window_hours' => 24,
|
||||||
|
'metrics' => [
|
||||||
|
'accuracy' => 0.92,
|
||||||
|
'precision' => 0.89,
|
||||||
|
'recall' => 0.94,
|
||||||
|
'f1_score' => 0.91,
|
||||||
|
'total_predictions' => 1523,
|
||||||
|
'average_confidence' => 0.85,
|
||||||
|
],
|
||||||
|
'confusion_matrix' => [
|
||||||
|
'true_positive' => 1234,
|
||||||
|
'true_negative' => 156,
|
||||||
|
'false_positive' => 89,
|
||||||
|
'false_negative' => 44,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 404,
|
||||||
|
description: 'Model not found',
|
||||||
|
)]
|
||||||
|
public function getMetrics(string $modelName, HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
$versionString = $request->queryParameters['version'] ?? null;
|
||||||
|
$timeWindowHours = (int) ($request->queryParameters['timeWindow'] ?? 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($versionString !== null) {
|
||||||
|
$version = Version::fromString($versionString);
|
||||||
|
} else {
|
||||||
|
$metadata = $this->registry->getLatest($modelName);
|
||||||
|
if ($metadata === null) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Model not found',
|
||||||
|
'model_name' => $modelName,
|
||||||
|
], Status::NOT_FOUND);
|
||||||
|
}
|
||||||
|
$version = $metadata->version;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timeWindow = Duration::fromHours($timeWindowHours);
|
||||||
|
$metrics = $this->performanceMonitor->getCurrentMetrics(
|
||||||
|
$modelName,
|
||||||
|
$version,
|
||||||
|
$timeWindow
|
||||||
|
);
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'model_name' => $modelName,
|
||||||
|
'version' => $version->toString(),
|
||||||
|
'time_window_hours' => $timeWindowHours,
|
||||||
|
'metrics' => [
|
||||||
|
'accuracy' => $metrics['accuracy'],
|
||||||
|
'precision' => $metrics['precision'] ?? null,
|
||||||
|
'recall' => $metrics['recall'] ?? null,
|
||||||
|
'f1_score' => $metrics['f1_score'] ?? null,
|
||||||
|
'total_predictions' => $metrics['total_predictions'],
|
||||||
|
'average_confidence' => $metrics['average_confidence'] ?? null,
|
||||||
|
],
|
||||||
|
'confusion_matrix' => $metrics['confusion_matrix'] ?? null,
|
||||||
|
'timestamp' => date('c'),
|
||||||
|
]);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Invalid parameters',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/models', method: Method::POST)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Register a new ML model',
|
||||||
|
description: 'Register a new machine learning model or version in the system',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiRequestBody(
|
||||||
|
description: 'Model metadata for registration',
|
||||||
|
required: true,
|
||||||
|
example: [
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'type' => 'supervised',
|
||||||
|
'version' => '2.0.0',
|
||||||
|
'configuration' => [
|
||||||
|
'threshold' => 0.75,
|
||||||
|
'algorithm' => 'xgboost',
|
||||||
|
'features' => 30,
|
||||||
|
],
|
||||||
|
'performance_metrics' => [
|
||||||
|
'accuracy' => 0.94,
|
||||||
|
'precision' => 0.91,
|
||||||
|
'recall' => 0.96,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 201,
|
||||||
|
description: 'Model registered successfully',
|
||||||
|
example: [
|
||||||
|
'model_name' => 'fraud-detector',
|
||||||
|
'version' => '2.0.0',
|
||||||
|
'created_at' => '2024-01-01T00:00:00Z',
|
||||||
|
'message' => 'Model registered successfully',
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 400,
|
||||||
|
description: 'Invalid model data',
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 409,
|
||||||
|
description: 'Model version already exists',
|
||||||
|
)]
|
||||||
|
public function registerModel(HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$data = $request->parsedBody->toArray();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!isset($data['model_name'], $data['type'], $data['version'])) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Missing required fields',
|
||||||
|
'required' => ['model_name', 'type', 'version'],
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse model type
|
||||||
|
$modelType = match (strtolower($data['type'])) {
|
||||||
|
'supervised' => ModelType::SUPERVISED,
|
||||||
|
'unsupervised' => ModelType::UNSUPERVISED,
|
||||||
|
'reinforcement' => ModelType::REINFORCEMENT,
|
||||||
|
default => throw new \InvalidArgumentException("Invalid model type: {$data['type']}")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create metadata
|
||||||
|
$metadata = new ModelMetadata(
|
||||||
|
modelName: $data['model_name'],
|
||||||
|
modelType: $modelType,
|
||||||
|
version: Version::fromString($data['version']),
|
||||||
|
configuration: $data['configuration'] ?? [],
|
||||||
|
createdAt: Timestamp::now(),
|
||||||
|
performanceMetrics: $data['performance_metrics'] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
$existing = $this->registry->get($metadata->modelName, $metadata->version);
|
||||||
|
if ($existing !== null) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Model version already exists',
|
||||||
|
'model_name' => $metadata->modelName,
|
||||||
|
'version' => $metadata->version->toString(),
|
||||||
|
], Status::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register model
|
||||||
|
$this->registry->register($metadata);
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'model_name' => $metadata->modelName,
|
||||||
|
'version' => $metadata->version->toString(),
|
||||||
|
'type' => $metadata->modelType->value,
|
||||||
|
'created_at' => $metadata->createdAt->format('Y-m-d\TH:i:s\Z'),
|
||||||
|
'message' => 'Model registered successfully',
|
||||||
|
], Status::CREATED);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Invalid model data',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Failed to register model',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Status::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/api/ml/models/{modelName}', method: Method::DELETE)]
|
||||||
|
#[ApiEndpoint(
|
||||||
|
summary: 'Unregister ML model',
|
||||||
|
description: 'Remove a specific version of an ML model from the registry',
|
||||||
|
tags: ['Machine Learning'],
|
||||||
|
)]
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'modelName',
|
||||||
|
in: 'path',
|
||||||
|
description: 'Model identifier',
|
||||||
|
required: true,
|
||||||
|
type: 'string',
|
||||||
|
example: 'fraud-detector',
|
||||||
|
)]
|
||||||
|
#[ApiParameter(
|
||||||
|
name: 'version',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Model version to unregister',
|
||||||
|
required: true,
|
||||||
|
type: 'string',
|
||||||
|
example: '1.0.0',
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 200,
|
||||||
|
description: 'Model unregistered successfully',
|
||||||
|
)]
|
||||||
|
#[ApiResponse(
|
||||||
|
statusCode: 404,
|
||||||
|
description: 'Model not found',
|
||||||
|
)]
|
||||||
|
public function unregisterModel(string $modelName, HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
$versionString = $request->queryParameters['version'] ?? null;
|
||||||
|
|
||||||
|
if ($versionString === null) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Version parameter is required',
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$version = Version::fromString($versionString);
|
||||||
|
|
||||||
|
// Check if model exists
|
||||||
|
$metadata = $this->registry->get($modelName, $version);
|
||||||
|
if ($metadata === null) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Model not found',
|
||||||
|
'model_name' => $modelName,
|
||||||
|
'version' => $versionString,
|
||||||
|
], Status::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister
|
||||||
|
$this->registry->unregister($modelName, $version);
|
||||||
|
|
||||||
|
return new JsonResult([
|
||||||
|
'message' => 'Model unregistered successfully',
|
||||||
|
'model_name' => $modelName,
|
||||||
|
'version' => $versionString,
|
||||||
|
]);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return new JsonResult([
|
||||||
|
'error' => 'Invalid version format',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Status::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -285,7 +285,8 @@ final class PerformanceBasedAnalyticsStorage implements AnalyticsStorage
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$filename = $this->dataPath . '/raw_' . date('Y-m-d_H-i-s') . '_' . uniqid() . '.' . $this->serializer->getFileExtension();
|
$generator = new \App\Framework\Ulid\UlidGenerator();
|
||||||
|
$filename = $this->dataPath . '/raw_' . date('Y-m-d_H-i-s') . '_' . $generator->generate() . '.' . $this->serializer->getFileExtension();
|
||||||
$content = $this->serializer->serialize($this->rawDataBuffer);
|
$content = $this->serializer->serialize($this->rawDataBuffer);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
117
src/Framework/Core/ValueObjects/PhoneNumber.php
Normal file
117
src/Framework/Core/ValueObjects/PhoneNumber.php
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Core\ValueObjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone number value object
|
||||||
|
*
|
||||||
|
* Validates and formats phone numbers in E.164 format
|
||||||
|
* E.164: +[country code][subscriber number]
|
||||||
|
* Example: +4917612345678
|
||||||
|
*/
|
||||||
|
final readonly class PhoneNumber
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $value
|
||||||
|
) {
|
||||||
|
if (!$this->isValid($value)) {
|
||||||
|
throw new \InvalidArgumentException("Invalid phone number format: {$value}. Must be in E.164 format (+country code + number)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from international format (+country code + number)
|
||||||
|
*/
|
||||||
|
public static function fromInternational(string $countryCode, string $number): self
|
||||||
|
{
|
||||||
|
$cleaned = preg_replace('/[^0-9]/', '', $number);
|
||||||
|
|
||||||
|
if (empty($cleaned)) {
|
||||||
|
throw new \InvalidArgumentException('Phone number cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self("+{$countryCode}{$cleaned}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate E.164 format
|
||||||
|
* - Must start with +
|
||||||
|
* - Followed by 7-15 digits
|
||||||
|
* - No spaces or special characters
|
||||||
|
*/
|
||||||
|
private function isValid(string $value): bool
|
||||||
|
{
|
||||||
|
// E.164 format: +[country code][number]
|
||||||
|
// Max 15 digits total
|
||||||
|
if (!str_starts_with($value, '+')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$numbers = substr($value, 1);
|
||||||
|
|
||||||
|
if (!ctype_digit($numbers)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$length = strlen($numbers);
|
||||||
|
|
||||||
|
return $length >= 7 && $length <= 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get country code from phone number
|
||||||
|
* Note: This is a simple extraction, not validation against actual country codes
|
||||||
|
*/
|
||||||
|
public function getCountryCode(): string
|
||||||
|
{
|
||||||
|
// Extract 1-3 digits after +
|
||||||
|
preg_match('/^\+(\d{1,3})/', $this->value, $matches);
|
||||||
|
return $matches[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscriber number (without country code)
|
||||||
|
*/
|
||||||
|
public function getSubscriberNumber(): string
|
||||||
|
{
|
||||||
|
$countryCode = $this->getCountryCode();
|
||||||
|
return substr($this->value, strlen($countryCode) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format for display (e.g., +49 176 12345678)
|
||||||
|
*/
|
||||||
|
public function toDisplayFormat(): string
|
||||||
|
{
|
||||||
|
$countryCode = $this->getCountryCode();
|
||||||
|
$subscriber = $this->getSubscriberNumber();
|
||||||
|
|
||||||
|
// Format subscriber number in groups
|
||||||
|
$formatted = '+' . $countryCode . ' ';
|
||||||
|
$formatted .= chunk_split($subscriber, 3, ' ');
|
||||||
|
|
||||||
|
return rtrim($formatted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ use App\Framework\Database\Migration\ValueObjects\MemoryThresholds;
|
|||||||
use App\Framework\Database\Migration\ValueObjects\MigrationTableConfig;
|
use App\Framework\Database\Migration\ValueObjects\MigrationTableConfig;
|
||||||
use App\Framework\Database\Platform\DatabasePlatform;
|
use App\Framework\Database\Platform\DatabasePlatform;
|
||||||
use App\Framework\DateTime\Clock;
|
use App\Framework\DateTime\Clock;
|
||||||
use App\Framework\Exception\ErrorCode;
|
use App\Framework\Exception\Core\DatabaseErrorCode;
|
||||||
use App\Framework\Exception\ExceptionContext;
|
use App\Framework\Exception\ExceptionContext;
|
||||||
use App\Framework\Exception\FrameworkException;
|
use App\Framework\Exception\FrameworkException;
|
||||||
use App\Framework\Logging\Logger;
|
use App\Framework\Logging\Logger;
|
||||||
@@ -22,6 +22,7 @@ use App\Framework\Performance\MemoryMonitor;
|
|||||||
use App\Framework\Performance\OperationTracker;
|
use App\Framework\Performance\OperationTracker;
|
||||||
use App\Framework\Performance\PerformanceReporter;
|
use App\Framework\Performance\PerformanceReporter;
|
||||||
use App\Framework\Performance\Repository\PerformanceMetricsRepository;
|
use App\Framework\Performance\Repository\PerformanceMetricsRepository;
|
||||||
|
use App\Framework\Ulid\UlidGenerator;
|
||||||
|
|
||||||
final readonly class MigrationRunner
|
final readonly class MigrationRunner
|
||||||
{
|
{
|
||||||
@@ -41,6 +42,7 @@ final readonly class MigrationRunner
|
|||||||
private ConnectionInterface $connection,
|
private ConnectionInterface $connection,
|
||||||
private DatabasePlatform $platform,
|
private DatabasePlatform $platform,
|
||||||
private Clock $clock,
|
private Clock $clock,
|
||||||
|
private UlidGenerator $ulidGenerator,
|
||||||
?MigrationTableConfig $tableConfig = null,
|
?MigrationTableConfig $tableConfig = null,
|
||||||
?Logger $logger = null,
|
?Logger $logger = null,
|
||||||
?OperationTracker $operationTracker = null,
|
?OperationTracker $operationTracker = null,
|
||||||
@@ -107,7 +109,7 @@ final readonly class MigrationRunner
|
|||||||
$totalMigrations = $orderedMigrations->count();
|
$totalMigrations = $orderedMigrations->count();
|
||||||
|
|
||||||
// Start batch tracking
|
// Start batch tracking
|
||||||
$batchOperationId = 'migration_batch_' . uniqid();
|
$batchOperationId = 'migration_batch_' . $this->ulidGenerator->generate();
|
||||||
$this->performanceTracker->startBatchOperation($batchOperationId, $totalMigrations);
|
$this->performanceTracker->startBatchOperation($batchOperationId, $totalMigrations);
|
||||||
|
|
||||||
$currentPosition = 0;
|
$currentPosition = 0;
|
||||||
@@ -198,7 +200,7 @@ final readonly class MigrationRunner
|
|||||||
$migrationContext = $this->errorAnalyzer->analyzeMigrationContext($migration, $version, $e);
|
$migrationContext = $this->errorAnalyzer->analyzeMigrationContext($migration, $version, $e);
|
||||||
|
|
||||||
throw FrameworkException::create(
|
throw FrameworkException::create(
|
||||||
ErrorCode::DB_MIGRATION_FAILED,
|
DatabaseErrorCode::MIGRATION_FAILED,
|
||||||
"Migration {$version} failed: {$e->getMessage()}"
|
"Migration {$version} failed: {$e->getMessage()}"
|
||||||
)->withContext(
|
)->withContext(
|
||||||
ExceptionContext::forOperation('migration.execute', 'MigrationRunner')
|
ExceptionContext::forOperation('migration.execute', 'MigrationRunner')
|
||||||
@@ -252,7 +254,7 @@ final readonly class MigrationRunner
|
|||||||
$totalRollbacks = count($versionsToRollback);
|
$totalRollbacks = count($versionsToRollback);
|
||||||
|
|
||||||
// Start rollback batch tracking
|
// Start rollback batch tracking
|
||||||
$rollbackBatchId = 'rollback_batch_' . uniqid();
|
$rollbackBatchId = 'rollback_batch_' . $this->ulidGenerator->generate();
|
||||||
$this->performanceTracker->startBatchOperation($rollbackBatchId, $totalRollbacks);
|
$this->performanceTracker->startBatchOperation($rollbackBatchId, $totalRollbacks);
|
||||||
|
|
||||||
$currentPosition = 0;
|
$currentPosition = 0;
|
||||||
@@ -269,7 +271,7 @@ final readonly class MigrationRunner
|
|||||||
// CRITICAL SAFETY CHECK: Ensure migration supports safe rollback
|
// CRITICAL SAFETY CHECK: Ensure migration supports safe rollback
|
||||||
if (! $migration instanceof SafelyReversible) {
|
if (! $migration instanceof SafelyReversible) {
|
||||||
throw FrameworkException::create(
|
throw FrameworkException::create(
|
||||||
ErrorCode::DB_MIGRATION_NOT_REVERSIBLE,
|
DatabaseErrorCode::MIGRATION_NOT_REVERSIBLE,
|
||||||
"Migration {$version} does not support safe rollback"
|
"Migration {$version} does not support safe rollback"
|
||||||
)->withContext(
|
)->withContext(
|
||||||
ExceptionContext::forOperation('migration.rollback', 'MigrationRunner')
|
ExceptionContext::forOperation('migration.rollback', 'MigrationRunner')
|
||||||
@@ -353,7 +355,7 @@ final readonly class MigrationRunner
|
|||||||
$recoveryHints = $this->errorAnalyzer->generateRollbackRecoveryHints($migration, $e, $remainingRollbacks);
|
$recoveryHints = $this->errorAnalyzer->generateRollbackRecoveryHints($migration, $e, $remainingRollbacks);
|
||||||
|
|
||||||
throw FrameworkException::create(
|
throw FrameworkException::create(
|
||||||
ErrorCode::DB_MIGRATION_ROLLBACK_FAILED,
|
DatabaseErrorCode::MIGRATION_ROLLBACK_FAILED,
|
||||||
"Rollback failed for migration {$version}: {$e->getMessage()}"
|
"Rollback failed for migration {$version}: {$e->getMessage()}"
|
||||||
)->withContext(
|
)->withContext(
|
||||||
ExceptionContext::forOperation('migration.rollback', 'MigrationRunner')
|
ExceptionContext::forOperation('migration.rollback', 'MigrationRunner')
|
||||||
@@ -437,7 +439,7 @@ final readonly class MigrationRunner
|
|||||||
// Throw exception if critical issues found
|
// Throw exception if critical issues found
|
||||||
if (! empty($criticalIssues)) {
|
if (! empty($criticalIssues)) {
|
||||||
throw FrameworkException::create(
|
throw FrameworkException::create(
|
||||||
ErrorCode::DB_MIGRATION_PREFLIGHT_FAILED,
|
DatabaseErrorCode::MIGRATION_PREFLIGHT_FAILED,
|
||||||
'Pre-flight checks failed with critical issues'
|
'Pre-flight checks failed with critical issues'
|
||||||
)->withData([
|
)->withData([
|
||||||
'critical_issues' => $criticalIssues,
|
'critical_issues' => $criticalIssues,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Framework\Deployment\Docker\Commands;
|
namespace App\Framework\Deployment\Docker\Commands;
|
||||||
|
|
||||||
use App\Framework\Console\Attribute\ConsoleCommand;
|
use App\Framework\Console\ConsoleCommand;
|
||||||
use App\Framework\Console\ConsoleInput;
|
use App\Framework\Console\ConsoleInput;
|
||||||
use App\Framework\Console\ExitCode;
|
use App\Framework\Console\ExitCode;
|
||||||
use App\Framework\Deployment\Docker\Services\DockerDeploymentService;
|
use App\Framework\Deployment\Docker\Services\DockerDeploymentService;
|
||||||
@@ -25,20 +25,12 @@ final readonly class DockerDeploymentCommands
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[ConsoleCommand(name: 'docker:deploy:restart', description: 'Restart container with health checks and deployment tracking')]
|
#[ConsoleCommand(name: 'docker:deploy:restart', description: 'Restart container with health checks and deployment tracking')]
|
||||||
public function deployRestart(ConsoleInput $input): int
|
public function deployRestart(string $container, ?bool $noHealthCheck = null): ExitCode
|
||||||
{
|
{
|
||||||
$containerName = $input->getArgument('container');
|
$healthCheck = $noHealthCheck !== true;
|
||||||
|
$containerId = ContainerId::fromString($container);
|
||||||
|
|
||||||
if ($containerName === null) {
|
echo "🚀 Starting deployment: Restart container '{$container}'\n";
|
||||||
echo "❌ Please provide a container ID or name.\n";
|
|
||||||
echo "Usage: php console.php docker:deploy:restart <container> [--no-health-check]\n";
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$healthCheck = !$input->hasOption('no-health-check');
|
|
||||||
$containerId = ContainerId::fromString($containerName);
|
|
||||||
|
|
||||||
echo "🚀 Starting deployment: Restart container '{$containerName}'\n";
|
|
||||||
if ($healthCheck) {
|
if ($healthCheck) {
|
||||||
echo " Health checks: ENABLED\n";
|
echo " Health checks: ENABLED\n";
|
||||||
} else {
|
} else {
|
||||||
@@ -50,34 +42,25 @@ final readonly class DockerDeploymentCommands
|
|||||||
|
|
||||||
if ($result->isSuccess()) {
|
if ($result->isSuccess()) {
|
||||||
echo "✅ Deployment succeeded!\n";
|
echo "✅ Deployment succeeded!\n";
|
||||||
echo " Container: {$containerName}\n";
|
echo " Container: {$container}\n";
|
||||||
echo " Duration: {$result->duration->toHumanReadable()}\n";
|
echo " Duration: {$result->duration->toHumanReadable()}\n";
|
||||||
echo " Message: {$result->message}\n";
|
echo " Message: {$result->message}\n";
|
||||||
return ExitCode::SUCCESS;
|
return ExitCode::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "❌ Deployment failed!\n";
|
echo "❌ Deployment failed!\n";
|
||||||
echo " Container: {$containerName}\n";
|
echo " Container: {$container}\n";
|
||||||
echo " Duration: {$result->duration->toHumanReadable()}\n";
|
echo " Duration: {$result->duration->toHumanReadable()}\n";
|
||||||
echo " Error: {$result->error}\n";
|
echo " Error: {$result->error}\n";
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ConsoleCommand(name: 'docker:deploy:stop', description: 'Stop container gracefully with timeout')]
|
#[ConsoleCommand(name: 'docker:deploy:stop', description: 'Stop container gracefully with timeout')]
|
||||||
public function deployStop(ConsoleInput $input): int
|
public function deployStop(string $container, int $timeout = 10): ExitCode
|
||||||
{
|
{
|
||||||
$containerName = $input->getArgument('container');
|
$containerId = ContainerId::fromString($container);
|
||||||
|
|
||||||
if ($containerName === null) {
|
echo "🛑 Stopping container: {$container}\n";
|
||||||
echo "❌ Please provide a container ID or name.\n";
|
|
||||||
echo "Usage: php console.php docker:deploy:stop <container> [--timeout=10]\n";
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$timeout = (int) ($input->getOption('timeout') ?? 10);
|
|
||||||
$containerId = ContainerId::fromString($containerName);
|
|
||||||
|
|
||||||
echo "🛑 Stopping container: {$containerName}\n";
|
|
||||||
echo " Timeout: {$timeout}s\n\n";
|
echo " Timeout: {$timeout}s\n\n";
|
||||||
|
|
||||||
$success = $this->deploymentService->stopContainer($containerId, $timeout);
|
$success = $this->deploymentService->stopContainer($containerId, $timeout);
|
||||||
@@ -92,20 +75,12 @@ final readonly class DockerDeploymentCommands
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[ConsoleCommand(name: 'docker:deploy:start', description: 'Start container with health check')]
|
#[ConsoleCommand(name: 'docker:deploy:start', description: 'Start container with health check')]
|
||||||
public function deployStart(ConsoleInput $input): int
|
public function deployStart(string $container, ?bool $noHealthCheck = null): ExitCode
|
||||||
{
|
{
|
||||||
$containerName = $input->getArgument('container');
|
$healthCheck = $noHealthCheck !== true;
|
||||||
|
$containerId = ContainerId::fromString($container);
|
||||||
|
|
||||||
if ($containerName === null) {
|
echo "▶️ Starting container: {$container}\n";
|
||||||
echo "❌ Please provide a container ID or name.\n";
|
|
||||||
echo "Usage: php console.php docker:deploy:start <container> [--no-health-check]\n";
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$healthCheck = !$input->hasOption('no-health-check');
|
|
||||||
$containerId = ContainerId::fromString($containerName);
|
|
||||||
|
|
||||||
echo "▶️ Starting container: {$containerName}\n";
|
|
||||||
if ($healthCheck) {
|
if ($healthCheck) {
|
||||||
echo " Health checks: ENABLED\n";
|
echo " Health checks: ENABLED\n";
|
||||||
}
|
}
|
||||||
@@ -130,25 +105,16 @@ final readonly class DockerDeploymentCommands
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[ConsoleCommand(name: 'docker:deploy:logs', description: 'Get container logs for deployment debugging')]
|
#[ConsoleCommand(name: 'docker:deploy:logs', description: 'Get container logs for deployment debugging')]
|
||||||
public function deployLogs(ConsoleInput $input): int
|
public function deployLogs(string $container, int $lines = 100): ExitCode
|
||||||
{
|
{
|
||||||
$containerName = $input->getArgument('container');
|
$containerId = ContainerId::fromString($container);
|
||||||
|
|
||||||
if ($containerName === null) {
|
echo "📋 Loading logs for: {$container} (last {$lines} lines)\n\n";
|
||||||
echo "❌ Please provide a container ID or name.\n";
|
|
||||||
echo "Usage: php console.php docker:deploy:logs <container> [--lines=100]\n";
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines = (int) ($input->getOption('lines') ?? 100);
|
|
||||||
$containerId = ContainerId::fromString($containerName);
|
|
||||||
|
|
||||||
echo "📋 Loading logs for: {$containerName} (last {$lines} lines)\n\n";
|
|
||||||
|
|
||||||
$logs = $this->deploymentService->getContainerLogs($containerId, $lines);
|
$logs = $this->deploymentService->getContainerLogs($containerId, $lines);
|
||||||
|
|
||||||
if ($logs === null) {
|
if ($logs === null) {
|
||||||
echo "❌ Could not retrieve logs for container: {$containerName}\n";
|
echo "❌ Could not retrieve logs for container: {$container}\n";
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +125,7 @@ final readonly class DockerDeploymentCommands
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[ConsoleCommand(name: 'docker:deploy:status', description: 'Show deployment status of containers')]
|
#[ConsoleCommand(name: 'docker:deploy:status', description: 'Show deployment status of containers')]
|
||||||
public function deployStatus(ConsoleInput $input): int
|
public function deployStatus(): ExitCode
|
||||||
{
|
{
|
||||||
echo "📊 Docker Deployment Status\n\n";
|
echo "📊 Docker Deployment Status\n\n";
|
||||||
|
|
||||||
@@ -185,7 +151,7 @@ final readonly class DockerDeploymentCommands
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[ConsoleCommand(name: 'docker:deploy:exec', description: 'Execute command in container for deployment tasks')]
|
#[ConsoleCommand(name: 'docker:deploy:exec', description: 'Execute command in container for deployment tasks')]
|
||||||
public function deployExec(ConsoleInput $input): int
|
public function deployExec(ConsoleInput $input): ExitCode
|
||||||
{
|
{
|
||||||
$containerName = $input->getArgument('container');
|
$containerName = $input->getArgument('container');
|
||||||
$command = $input->getArgument('command');
|
$command = $input->getArgument('command');
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ declare(strict_types=1);
|
|||||||
namespace App\Framework\Deployment\Pipeline\Commands;
|
namespace App\Framework\Deployment\Pipeline\Commands;
|
||||||
|
|
||||||
use App\Framework\Console\Attribute\ConsoleCommand;
|
use App\Framework\Console\Attribute\ConsoleCommand;
|
||||||
use App\Framework\Console\ConsoleInput;
|
|
||||||
use App\Framework\Console\ExitCode;
|
use App\Framework\Console\ExitCode;
|
||||||
use App\Framework\Deployment\Pipeline\Services\DeploymentPipelineService;
|
use App\Framework\Deployment\Pipeline\Services\DeploymentPipelineService;
|
||||||
|
use App\Framework\Deployment\Pipeline\Stages\AnsibleDeployStage;
|
||||||
use App\Framework\Deployment\Pipeline\Stages\BuildStage;
|
use App\Framework\Deployment\Pipeline\Stages\BuildStage;
|
||||||
use App\Framework\Deployment\Pipeline\Stages\DeployStage;
|
use App\Framework\Deployment\Pipeline\Stages\DeployStage;
|
||||||
use App\Framework\Deployment\Pipeline\Stages\HealthCheckStage;
|
use App\Framework\Deployment\Pipeline\Stages\HealthCheckStage;
|
||||||
@@ -26,24 +26,27 @@ final readonly class DeploymentPipelineCommands
|
|||||||
private BuildStage $buildStage,
|
private BuildStage $buildStage,
|
||||||
private TestStage $testStage,
|
private TestStage $testStage,
|
||||||
private DeployStage $deployStage,
|
private DeployStage $deployStage,
|
||||||
|
private AnsibleDeployStage $ansibleDeployStage,
|
||||||
private HealthCheckStage $healthCheckStage
|
private HealthCheckStage $healthCheckStage
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
#[ConsoleCommand(name: 'deploy:dev', description: 'Deploy to development environment')]
|
#[ConsoleCommand(name: 'deploy:dev', description: 'Deploy to development environment')]
|
||||||
public function deployDev(ConsoleInput $input): int
|
public function deployDev(): ExitCode
|
||||||
{
|
{
|
||||||
return $this->runPipeline(DeploymentEnvironment::DEVELOPMENT);
|
return $this->runPipeline(DeploymentEnvironment::DEVELOPMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ConsoleCommand(name: 'deploy:staging', description: 'Deploy to staging environment')]
|
#[ConsoleCommand(name: 'deploy:staging', description: 'Deploy to staging environment')]
|
||||||
public function deployStaging(ConsoleInput $input): int
|
public function deployStaging(): ExitCode
|
||||||
{
|
{
|
||||||
return $this->runPipeline(DeploymentEnvironment::STAGING);
|
return $this->runPipeline(DeploymentEnvironment::STAGING);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ConsoleCommand(name: 'deploy:production', description: 'Deploy to production environment')]
|
#[ConsoleCommand(name: 'deploy:production', description: 'Deploy to production environment')]
|
||||||
public function deployProduction(ConsoleInput $input): int
|
public function deployProduction(?bool $force = null): ExitCode
|
||||||
{
|
{
|
||||||
|
// Skip confirmation if --force flag is provided
|
||||||
|
if ($force !== true) {
|
||||||
echo "⚠️ Production Deployment\n";
|
echo "⚠️ Production Deployment\n";
|
||||||
echo " This will deploy to the production environment.\n";
|
echo " This will deploy to the production environment.\n";
|
||||||
echo " Are you sure? (yes/no): ";
|
echo " Are you sure? (yes/no): ";
|
||||||
@@ -54,6 +57,10 @@ final readonly class DeploymentPipelineCommands
|
|||||||
echo "❌ Production deployment cancelled.\n";
|
echo "❌ Production deployment cancelled.\n";
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
echo "⚠️ Production Deployment (forced)\n";
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
|
||||||
return $this->runPipeline(DeploymentEnvironment::PRODUCTION);
|
return $this->runPipeline(DeploymentEnvironment::PRODUCTION);
|
||||||
}
|
}
|
||||||
@@ -149,10 +156,10 @@ final readonly class DeploymentPipelineCommands
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production: Skip tests (already tested in staging)
|
// Production: Skip tests (already tested in staging), use Ansible for deployment
|
||||||
return [
|
return [
|
||||||
$this->buildStage,
|
$this->buildStage,
|
||||||
$this->deployStage,
|
$this->ansibleDeployStage, // Use Ansible for production deployments
|
||||||
$this->healthCheckStage,
|
$this->healthCheckStage,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Deployment\Pipeline;
|
||||||
|
|
||||||
|
use App\Framework\Config\Environment;
|
||||||
|
use App\Framework\DI\Container;
|
||||||
|
use App\Framework\DI\Initializer;
|
||||||
|
use App\Framework\Deployment\Pipeline\Stages\AnsibleDeployStage;
|
||||||
|
use App\Framework\Filesystem\ValueObjects\FilePath;
|
||||||
|
use App\Framework\Logging\Logger;
|
||||||
|
use App\Framework\Process\Process;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deployment Pipeline Initializer
|
||||||
|
*
|
||||||
|
* Registers deployment pipeline components with the DI container.
|
||||||
|
*/
|
||||||
|
final readonly class DeploymentPipelineInitializer
|
||||||
|
{
|
||||||
|
#[Initializer]
|
||||||
|
public function initialize(Container $container): void
|
||||||
|
{
|
||||||
|
// Register AnsibleDeployStage
|
||||||
|
$container->bind(AnsibleDeployStage::class, function (Container $container) {
|
||||||
|
$env = $container->get(Environment::class);
|
||||||
|
|
||||||
|
// Get paths from environment or use defaults
|
||||||
|
$projectRoot = $env->get('PROJECT_ROOT', '/home/michael/dev/michaelschiemer');
|
||||||
|
$inventoryPath = FilePath::fromString($projectRoot . '/deployment/infrastructure/inventories');
|
||||||
|
$playbookPath = FilePath::fromString($projectRoot . '/deployment/infrastructure/playbooks/deploy-rsync-based.yml');
|
||||||
|
|
||||||
|
return new AnsibleDeployStage(
|
||||||
|
process: $container->get(Process::class),
|
||||||
|
logger: $container->get(Logger::class),
|
||||||
|
ansibleInventoryPath: $inventoryPath,
|
||||||
|
ansiblePlaybookPath: $playbookPath
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,6 @@ use App\Framework\Database\Migration\Migration;
|
|||||||
use App\Framework\Database\Migration\MigrationVersion;
|
use App\Framework\Database\Migration\MigrationVersion;
|
||||||
use App\Framework\Database\Schema\Blueprint;
|
use App\Framework\Database\Schema\Blueprint;
|
||||||
use App\Framework\Database\Schema\Schema;
|
use App\Framework\Database\Schema\Schema;
|
||||||
use App\Framework\Database\ValueObjects\TableName;
|
|
||||||
use App\Framework\Database\ValueObjects\ColumnName;
|
|
||||||
use App\Framework\Database\ValueObjects\IndexName;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create pipeline_history table for deployment tracking
|
* Create pipeline_history table for deployment tracking
|
||||||
@@ -22,46 +19,45 @@ final readonly class CreatePipelineHistoryTable implements Migration
|
|||||||
{
|
{
|
||||||
$schema = new Schema($connection);
|
$schema = new Schema($connection);
|
||||||
|
|
||||||
$schema->create(TableName::fromString('pipeline_history'), function (Blueprint $table) {
|
$schema->createIfNotExists('pipeline_history', function (Blueprint $table) {
|
||||||
// Primary identifier
|
// Primary identifier
|
||||||
$table->string(ColumnName::fromString('pipeline_id'), 26)->primary();
|
$table->string('pipeline_id', 26)->primary();
|
||||||
|
|
||||||
// Environment and status
|
// Environment and status
|
||||||
$table->string(ColumnName::fromString('environment'), 50);
|
$table->string('environment', 50);
|
||||||
$table->string(ColumnName::fromString('status'), 50);
|
$table->string('status', 50);
|
||||||
|
|
||||||
// Execution details
|
// Execution details
|
||||||
$table->json(ColumnName::fromString('stages_data')); // Stage results as JSON
|
$table->text('stages_data'); // Stage results as JSON
|
||||||
$table->integer(ColumnName::fromString('total_duration_ms'));
|
$table->integer('total_duration_ms');
|
||||||
$table->text(ColumnName::fromString('error'))->nullable();
|
$table->text('error')->nullable();
|
||||||
|
|
||||||
// Rollback information
|
// Rollback information
|
||||||
$table->boolean(ColumnName::fromString('was_rolled_back'))->default(false);
|
$table->boolean('was_rolled_back')->default(false);
|
||||||
$table->string(ColumnName::fromString('failed_stage'), 50)->nullable();
|
$table->string('failed_stage', 50)->nullable();
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
$table->timestamp(ColumnName::fromString('started_at'));
|
$table->timestamp('started_at')->useCurrent();
|
||||||
$table->timestamp(ColumnName::fromString('completed_at'));
|
$table->timestamp('completed_at')->nullable();
|
||||||
|
|
||||||
// Indexes for querying
|
// Indexes for querying
|
||||||
$table->index(
|
$table->index(['environment', 'status'], 'idx_pipeline_history_env_status');
|
||||||
ColumnName::fromString('environment'),
|
$table->index(['completed_at'], 'idx_pipeline_history_completed');
|
||||||
ColumnName::fromString('status'),
|
|
||||||
IndexName::fromString('idx_pipeline_history_env_status')
|
|
||||||
);
|
|
||||||
|
|
||||||
$table->index(
|
|
||||||
ColumnName::fromString('completed_at'),
|
|
||||||
IndexName::fromString('idx_pipeline_history_completed')
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$schema->execute();
|
$schema->execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function down(ConnectionInterface $connection): void
|
||||||
|
{
|
||||||
|
$schema = new Schema($connection);
|
||||||
|
$schema->dropIfExists('pipeline_history');
|
||||||
|
$schema->execute();
|
||||||
|
}
|
||||||
|
|
||||||
public function getVersion(): MigrationVersion
|
public function getVersion(): MigrationVersion
|
||||||
{
|
{
|
||||||
return MigrationVersion::fromString('2024_12_19_180000');
|
return MigrationVersion::fromTimestamp('2024_12_19_180000');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDescription(): string
|
public function getDescription(): string
|
||||||
|
|||||||
225
src/Framework/Deployment/Pipeline/Stages/AnsibleDeployStage.php
Normal file
225
src/Framework/Deployment/Pipeline/Stages/AnsibleDeployStage.php
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Deployment\Pipeline\Stages;
|
||||||
|
|
||||||
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
use App\Framework\Deployment\Pipeline\ValueObjects\DeploymentEnvironment;
|
||||||
|
use App\Framework\Deployment\Pipeline\ValueObjects\PipelineStage;
|
||||||
|
use App\Framework\Deployment\Pipeline\ValueObjects\StageResult;
|
||||||
|
use App\Framework\Filesystem\ValueObjects\FilePath;
|
||||||
|
use App\Framework\Logging\Logger;
|
||||||
|
use App\Framework\Process\Process;
|
||||||
|
use App\Framework\Process\ValueObjects\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ansible Deploy Stage
|
||||||
|
*
|
||||||
|
* Deploys the application using Ansible playbooks for production deployments.
|
||||||
|
* Provides zero-downtime deployments with rollback capability.
|
||||||
|
*
|
||||||
|
* Uses the framework's Process module for secure and monitored command execution.
|
||||||
|
*/
|
||||||
|
final readonly class AnsibleDeployStage implements PipelineStageInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Process $process,
|
||||||
|
private Logger $logger,
|
||||||
|
private FilePath $ansibleInventoryPath,
|
||||||
|
private FilePath $ansiblePlaybookPath
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getStage(): PipelineStage
|
||||||
|
{
|
||||||
|
return PipelineStage::DEPLOY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(DeploymentEnvironment $environment): StageResult
|
||||||
|
{
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
$this->logger->info('Starting Ansible deployment', [
|
||||||
|
'environment' => $environment->value,
|
||||||
|
'playbook' => $this->ansiblePlaybookPath->toString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify ansible-playbook command exists
|
||||||
|
if (!$this->process->commandExists('ansible-playbook')) {
|
||||||
|
throw new \RuntimeException('ansible-playbook command not found. Please install Ansible.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build Ansible command
|
||||||
|
$command = $this->buildAnsibleCommand($environment);
|
||||||
|
$workingDir = FilePath::fromString(dirname($this->ansiblePlaybookPath->toString()));
|
||||||
|
|
||||||
|
$this->logger->debug('Executing Ansible command', [
|
||||||
|
'command' => $command->toString(),
|
||||||
|
'working_dir' => $workingDir->toString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Execute Ansible playbook via Process module
|
||||||
|
$result = $this->process->run(
|
||||||
|
command: $command,
|
||||||
|
workingDirectory: $workingDir,
|
||||||
|
timeout: Duration::fromMinutes(15) // Ansible deployment timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result->isFailed()) {
|
||||||
|
$errorMessage = "Ansible deployment failed with exit code {$result->exitCode->value}";
|
||||||
|
if ($result->hasErrors()) {
|
||||||
|
$errorMessage .= ":\n{$result->getErrorOutput()}";
|
||||||
|
}
|
||||||
|
if ($result->hasOutput()) {
|
||||||
|
$errorMessage .= "\n\nOutput:\n{$result->getOutput()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException($errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$duration = Duration::fromSeconds(microtime(true) - $startTime);
|
||||||
|
|
||||||
|
$this->logger->info('Ansible deployment completed', [
|
||||||
|
'duration' => $duration->toSeconds(),
|
||||||
|
'environment' => $environment->value,
|
||||||
|
'runtime_ms' => $result->runtime->toMilliseconds(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return StageResult::success(
|
||||||
|
stage: $this->getStage(),
|
||||||
|
duration: $duration,
|
||||||
|
output: "Ansible deployment completed successfully\n\n" . $result->getOutput()
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$duration = Duration::fromSeconds(microtime(true) - $startTime);
|
||||||
|
|
||||||
|
$this->logger->error('Ansible deployment failed', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'environment' => $environment->value,
|
||||||
|
'duration' => $duration->toSeconds(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return StageResult::failure(
|
||||||
|
stage: $this->getStage(),
|
||||||
|
duration: $duration,
|
||||||
|
error: $e->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canRollback(): bool
|
||||||
|
{
|
||||||
|
return true; // Ansible playbooks support rollback
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rollback(DeploymentEnvironment $environment): StageResult
|
||||||
|
{
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
$this->logger->warning('Rolling back Ansible deployment', [
|
||||||
|
'environment' => $environment->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build rollback command
|
||||||
|
$rollbackPlaybook = $this->getRollbackPlaybookPath();
|
||||||
|
$command = $this->buildRollbackCommand($environment, $rollbackPlaybook);
|
||||||
|
$workingDir = FilePath::fromString(dirname($rollbackPlaybook->toString()));
|
||||||
|
|
||||||
|
$this->logger->debug('Executing Ansible rollback command', [
|
||||||
|
'command' => $command->toString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Execute rollback via Process module
|
||||||
|
$result = $this->process->run(
|
||||||
|
command: $command,
|
||||||
|
workingDirectory: $workingDir,
|
||||||
|
timeout: Duration::fromMinutes(10) // Rollback timeout
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result->isFailed()) {
|
||||||
|
$errorMessage = "Ansible rollback failed with exit code {$result->exitCode->value}";
|
||||||
|
if ($result->hasErrors()) {
|
||||||
|
$errorMessage .= ":\n{$result->getErrorOutput()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException($errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$duration = Duration::fromSeconds(microtime(true) - $startTime);
|
||||||
|
|
||||||
|
$this->logger->info('Ansible rollback completed', [
|
||||||
|
'duration' => $duration->toSeconds(),
|
||||||
|
'runtime_ms' => $result->runtime->toMilliseconds(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return StageResult::success(
|
||||||
|
stage: $this->getStage(),
|
||||||
|
duration: $duration,
|
||||||
|
output: "Rollback completed successfully\n\n" . $result->getOutput()
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$duration = Duration::fromSeconds(microtime(true) - $startTime);
|
||||||
|
|
||||||
|
$this->logger->error('Ansible rollback failed', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return StageResult::failure(
|
||||||
|
stage: $this->getStage(),
|
||||||
|
duration: $duration,
|
||||||
|
error: $e->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Deploy application using Ansible';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shouldSkip(DeploymentEnvironment $environment): bool
|
||||||
|
{
|
||||||
|
// Only use Ansible for production deployments
|
||||||
|
return $environment !== DeploymentEnvironment::PRODUCTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildAnsibleCommand(DeploymentEnvironment $environment): Command
|
||||||
|
{
|
||||||
|
$inventoryPath = $this->getInventoryPath($environment);
|
||||||
|
$playbookPath = $this->ansiblePlaybookPath->toString();
|
||||||
|
|
||||||
|
return Command::fromString(
|
||||||
|
"ansible-playbook -i {$inventoryPath} {$playbookPath}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildRollbackCommand(DeploymentEnvironment $environment, FilePath $rollbackPlaybook): Command
|
||||||
|
{
|
||||||
|
$inventoryPath = $this->getInventoryPath($environment);
|
||||||
|
|
||||||
|
return Command::fromString(
|
||||||
|
"ansible-playbook -i {$inventoryPath} {$rollbackPlaybook->toString()}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getInventoryPath(DeploymentEnvironment $environment): string
|
||||||
|
{
|
||||||
|
$inventoryBase = dirname($this->ansibleInventoryPath->toString());
|
||||||
|
|
||||||
|
return match ($environment) {
|
||||||
|
DeploymentEnvironment::PRODUCTION => $inventoryBase . '/production/hosts.yml',
|
||||||
|
DeploymentEnvironment::STAGING => $inventoryBase . '/staging/hosts.yml',
|
||||||
|
DeploymentEnvironment::DEVELOPMENT => $inventoryBase . '/development/hosts.yml',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getRollbackPlaybookPath(): FilePath
|
||||||
|
{
|
||||||
|
$playbookDir = dirname($this->ansiblePlaybookPath->toString());
|
||||||
|
return FilePath::fromString($playbookDir . '/rollback-git-based.yml');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ namespace App\Framework\Deployment\Ssl\Commands;
|
|||||||
|
|
||||||
use App\Framework\Config\Environment;
|
use App\Framework\Config\Environment;
|
||||||
use App\Framework\Console\Attributes\ConsoleCommand;
|
use App\Framework\Console\Attributes\ConsoleCommand;
|
||||||
use App\Framework\Console\ConsoleInput;
|
|
||||||
use App\Framework\Console\ConsoleOutput;
|
use App\Framework\Console\ConsoleOutput;
|
||||||
use App\Framework\Console\ExitCode;
|
use App\Framework\Console\ExitCode;
|
||||||
use App\Framework\Deployment\Ssl\Services\SslCertificateService;
|
use App\Framework\Deployment\Ssl\Services\SslCertificateService;
|
||||||
@@ -28,10 +27,10 @@ final readonly class SslInitCommand
|
|||||||
private ConsoleOutput $output
|
private ConsoleOutput $output
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function execute(ConsoleInput $input): int
|
public function execute(): ExitCode
|
||||||
{
|
{
|
||||||
$this->output->writeln('🔒 Initializing SSL Certificates...');
|
$this->output->writeLine('🔒 Initializing SSL Certificates...');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load configuration from environment
|
// Load configuration from environment
|
||||||
@@ -43,69 +42,69 @@ final readonly class SslInitCommand
|
|||||||
// Test configuration first
|
// Test configuration first
|
||||||
$this->output->write('Testing configuration... ');
|
$this->output->write('Testing configuration... ');
|
||||||
if (!$this->sslService->test($config)) {
|
if (!$this->sslService->test($config)) {
|
||||||
$this->output->writeln('❌ Failed');
|
$this->output->writeLine('❌ Failed');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('Configuration test failed. Please check:');
|
$this->output->writeLine('Configuration test failed. Please check:');
|
||||||
$this->output->writeln(' - Domain DNS is correctly configured');
|
$this->output->writeLine(' - Domain DNS is correctly configured');
|
||||||
$this->output->writeln(' - Webroot directory is accessible');
|
$this->output->writeLine(' - Webroot directory is accessible');
|
||||||
$this->output->writeln(' - Port 80 is open and reachable');
|
$this->output->writeLine(' - Port 80 is open and reachable');
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
$this->output->writeln('✅ Passed');
|
$this->output->writeLine('✅ Passed');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
// Obtain certificate
|
// Obtain certificate
|
||||||
$this->output->writeln('Obtaining certificate...');
|
$this->output->writeLine('Obtaining certificate...');
|
||||||
$status = $this->sslService->obtain($config);
|
$status = $this->sslService->obtain($config);
|
||||||
|
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('✅ Certificate obtained successfully!');
|
$this->output->writeLine('✅ Certificate obtained successfully!');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
// Display certificate status
|
// Display certificate status
|
||||||
$this->displayCertificateStatus($status);
|
$this->displayCertificateStatus($status);
|
||||||
|
|
||||||
if ($config->mode->isStaging()) {
|
if ($config->mode->isStaging()) {
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('⚠️ Note: Staging mode certificate obtained (for testing)');
|
$this->output->writeLine('⚠️ Note: Staging mode certificate obtained (for testing)');
|
||||||
$this->output->writeln(' Set LETSENCRYPT_STAGING=0 in .env for production certificates');
|
$this->output->writeLine(' Set LETSENCRYPT_STAGING=0 in .env for production certificates');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('Next steps:');
|
$this->output->writeLine('Next steps:');
|
||||||
$this->output->writeln(' 1. Reload/restart your web server');
|
$this->output->writeLine(' 1. Reload/restart your web server');
|
||||||
$this->output->writeln(' 2. Test HTTPS access to your domain');
|
$this->output->writeLine(' 2. Test HTTPS access to your domain');
|
||||||
$this->output->writeln(' 3. Set up automatic renewal (ssl:renew)');
|
$this->output->writeLine(' 3. Set up automatic renewal (ssl:renew)');
|
||||||
|
|
||||||
return ExitCode::SUCCESS;
|
return ExitCode::SUCCESS;
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('❌ Error: ' . $e->getMessage());
|
$this->output->writeLine('❌ Error: ' . $e->getMessage());
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function displayConfiguration(SslConfiguration $config): void
|
private function displayConfiguration(SslConfiguration $config): void
|
||||||
{
|
{
|
||||||
$this->output->writeln('Configuration:');
|
$this->output->writeLine('Configuration:');
|
||||||
$this->output->writeln(' Domain: ' . $config->domain->value);
|
$this->output->writeLine(' Domain: ' . $config->domain->value);
|
||||||
$this->output->writeln(' Email: ' . $config->email->value);
|
$this->output->writeLine(' Email: ' . $config->email->value);
|
||||||
$this->output->writeln(' Mode: ' . $config->mode->value . ' (' . $config->mode->getDescription() . ')');
|
$this->output->writeLine(' Mode: ' . $config->mode->value . ' (' . $config->mode->getDescription() . ')');
|
||||||
$this->output->writeln(' Webroot: ' . $config->certbotWwwDir->toString());
|
$this->output->writeLine(' Webroot: ' . $config->certbotWwwDir->toString());
|
||||||
$this->output->writeln(' Config Dir: ' . $config->certbotConfDir->toString());
|
$this->output->writeLine(' Config Dir: ' . $config->certbotConfDir->toString());
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function displayCertificateStatus($status): void
|
private function displayCertificateStatus($status): void
|
||||||
{
|
{
|
||||||
$this->output->writeln('Certificate Information:');
|
$this->output->writeLine('Certificate Information:');
|
||||||
$this->output->writeln(' Valid From: ' . $status->notBefore?->format('Y-m-d H:i:s') ?? 'N/A');
|
$this->output->writeLine(' Valid From: ' . $status->notBefore?->format('Y-m-d H:i:s') ?? 'N/A');
|
||||||
$this->output->writeln(' Valid Until: ' . $status->notAfter?->format('Y-m-d H:i:s') ?? 'N/A');
|
$this->output->writeLine(' Valid Until: ' . $status->notAfter?->format('Y-m-d H:i:s') ?? 'N/A');
|
||||||
$this->output->writeln(' Days Until Expiry: ' . ($status->daysUntilExpiry ?? 'N/A'));
|
$this->output->writeLine(' Days Until Expiry: ' . ($status->daysUntilExpiry ?? 'N/A'));
|
||||||
$this->output->writeln(' Issuer: ' . ($status->issuer ?? 'N/A'));
|
$this->output->writeLine(' Issuer: ' . ($status->issuer ?? 'N/A'));
|
||||||
$this->output->writeln(' Subject: ' . ($status->subject ?? 'N/A'));
|
$this->output->writeLine(' Subject: ' . ($status->subject ?? 'N/A'));
|
||||||
$this->output->writeln(' Health Status: ' . $status->getHealthStatus());
|
$this->output->writeLine(' Health Status: ' . $status->getHealthStatus());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace App\Framework\Deployment\Ssl\Commands;
|
|||||||
|
|
||||||
use App\Framework\Config\Environment;
|
use App\Framework\Config\Environment;
|
||||||
use App\Framework\Console\Attributes\ConsoleCommand;
|
use App\Framework\Console\Attributes\ConsoleCommand;
|
||||||
use App\Framework\Console\ConsoleInput;
|
|
||||||
use App\Framework\Console\ConsoleOutput;
|
use App\Framework\Console\ConsoleOutput;
|
||||||
use App\Framework\Console\ExitCode;
|
use App\Framework\Console\ExitCode;
|
||||||
use App\Framework\Deployment\Ssl\Services\SslCertificateService;
|
use App\Framework\Deployment\Ssl\Services\SslCertificateService;
|
||||||
@@ -27,17 +26,17 @@ final readonly class SslRenewCommand
|
|||||||
private ConsoleOutput $output
|
private ConsoleOutput $output
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function execute(ConsoleInput $input): int
|
public function execute(?bool $force = null): ExitCode
|
||||||
{
|
{
|
||||||
$this->output->writeln('🔄 Renewing SSL Certificates...');
|
$this->output->writeLine('🔄 Renewing SSL Certificates...');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load configuration from environment
|
// Load configuration from environment
|
||||||
$config = SslConfiguration::fromEnvironment($this->environment);
|
$config = SslConfiguration::fromEnvironment($this->environment);
|
||||||
|
|
||||||
$this->output->writeln('Domain: ' . $config->domain->value);
|
$this->output->writeLine('Domain: ' . $config->domain->value);
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
// Check current status
|
// Check current status
|
||||||
$this->output->write('Checking current certificate status... ');
|
$this->output->write('Checking current certificate status... ');
|
||||||
@@ -47,55 +46,60 @@ final readonly class SslRenewCommand
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!$currentStatus->exists) {
|
if (!$currentStatus->exists) {
|
||||||
$this->output->writeln('❌ Not found');
|
$this->output->writeLine('❌ Not found');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('No certificate exists for this domain.');
|
$this->output->writeLine('No certificate exists for this domain.');
|
||||||
$this->output->writeln('Run "ssl:init" to obtain a new certificate first.');
|
$this->output->writeLine('Run "ssl:init" to obtain a new certificate first.');
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->output->writeln('✅ Found');
|
$this->output->writeLine('✅ Found');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
// Display current status
|
// Display current status
|
||||||
$this->output->writeln('Current Status:');
|
$this->output->writeLine('Current Status:');
|
||||||
$this->output->writeln(' Valid Until: ' . $currentStatus->notAfter?->format('Y-m-d H:i:s'));
|
$this->output->writeLine(' Valid Until: ' . $currentStatus->notAfter?->format('Y-m-d H:i:s'));
|
||||||
$this->output->writeln(' Days Until Expiry: ' . $currentStatus->daysUntilExpiry);
|
$this->output->writeLine(' Days Until Expiry: ' . $currentStatus->daysUntilExpiry);
|
||||||
$this->output->writeln(' Health: ' . $currentStatus->getHealthStatus());
|
$this->output->writeLine(' Health: ' . $currentStatus->getHealthStatus());
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
// Check if renewal is needed
|
// Check if renewal is needed
|
||||||
if (!$currentStatus->needsRenewal()) {
|
if (!$currentStatus->needsRenewal() && $force !== true) {
|
||||||
$this->output->writeln('ℹ️ Certificate does not need renewal yet.');
|
$this->output->writeLine('ℹ️ Certificate does not need renewal yet.');
|
||||||
$this->output->writeln(' Certificates are automatically renewed 30 days before expiry.');
|
$this->output->writeLine(' Certificates are automatically renewed 30 days before expiry.');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('Use --force flag to force renewal anyway (not implemented yet).');
|
$this->output->writeLine('Use --force flag to force renewal anyway.');
|
||||||
return ExitCode::SUCCESS;
|
return ExitCode::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($force === true && !$currentStatus->needsRenewal()) {
|
||||||
|
$this->output->writeLine('⚠️ Forcing renewal even though certificate is still valid...');
|
||||||
|
$this->output->writeLine('');
|
||||||
|
}
|
||||||
|
|
||||||
// Renew certificate
|
// Renew certificate
|
||||||
$this->output->writeln('Renewing certificate...');
|
$this->output->writeLine('Renewing certificate...');
|
||||||
$status = $this->sslService->renew($config);
|
$status = $this->sslService->renew($config);
|
||||||
|
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('✅ Certificate renewed successfully!');
|
$this->output->writeLine('✅ Certificate renewed successfully!');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
// Display new status
|
// Display new status
|
||||||
$this->output->writeln('New Certificate Information:');
|
$this->output->writeLine('New Certificate Information:');
|
||||||
$this->output->writeln(' Valid Until: ' . $status->notAfter?->format('Y-m-d H:i:s'));
|
$this->output->writeLine(' Valid Until: ' . $status->notAfter?->format('Y-m-d H:i:s'));
|
||||||
$this->output->writeln(' Days Until Expiry: ' . $status->daysUntilExpiry);
|
$this->output->writeLine(' Days Until Expiry: ' . $status->daysUntilExpiry);
|
||||||
$this->output->writeln(' Health: ' . $status->getHealthStatus());
|
$this->output->writeLine(' Health: ' . $status->getHealthStatus());
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
$this->output->writeln('Next step: Reload/restart your web server to use the new certificate');
|
$this->output->writeLine('Next step: Reload/restart your web server to use the new certificate');
|
||||||
|
|
||||||
return ExitCode::SUCCESS;
|
return ExitCode::SUCCESS;
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('❌ Error: ' . $e->getMessage());
|
$this->output->writeLine('❌ Error: ' . $e->getMessage());
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace App\Framework\Deployment\Ssl\Commands;
|
|||||||
|
|
||||||
use App\Framework\Config\Environment;
|
use App\Framework\Config\Environment;
|
||||||
use App\Framework\Console\Attributes\ConsoleCommand;
|
use App\Framework\Console\Attributes\ConsoleCommand;
|
||||||
use App\Framework\Console\ConsoleInput;
|
|
||||||
use App\Framework\Console\ConsoleOutput;
|
use App\Framework\Console\ConsoleOutput;
|
||||||
use App\Framework\Console\ExitCode;
|
use App\Framework\Console\ExitCode;
|
||||||
use App\Framework\Deployment\Ssl\Services\SslCertificateService;
|
use App\Framework\Deployment\Ssl\Services\SslCertificateService;
|
||||||
@@ -27,17 +26,17 @@ final readonly class SslStatusCommand
|
|||||||
private ConsoleOutput $output
|
private ConsoleOutput $output
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function execute(ConsoleInput $input): int
|
public function execute(): ExitCode
|
||||||
{
|
{
|
||||||
$this->output->writeln('📋 SSL Certificate Status');
|
$this->output->writeLine('📋 SSL Certificate Status');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load configuration from environment
|
// Load configuration from environment
|
||||||
$config = SslConfiguration::fromEnvironment($this->environment);
|
$config = SslConfiguration::fromEnvironment($this->environment);
|
||||||
|
|
||||||
$this->output->writeln('Domain: ' . $config->domain->value);
|
$this->output->writeLine('Domain: ' . $config->domain->value);
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
// Get certificate status
|
// Get certificate status
|
||||||
$status = $this->sslService->getStatus(
|
$status = $this->sslService->getStatus(
|
||||||
@@ -46,9 +45,9 @@ final readonly class SslStatusCommand
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!$status->exists) {
|
if (!$status->exists) {
|
||||||
$this->output->writeln('❌ No certificate found for this domain');
|
$this->output->writeLine('❌ No certificate found for this domain');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('Run "ssl:init" to obtain a certificate.');
|
$this->output->writeLine('Run "ssl:init" to obtain a certificate.');
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,70 +64,70 @@ final readonly class SslStatusCommand
|
|||||||
default => 'ℹ️ '
|
default => 'ℹ️ '
|
||||||
};
|
};
|
||||||
|
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln($healthEmoji . ' Health Status: ' . strtoupper($status->getHealthStatus()));
|
$this->output->writeLine($healthEmoji . ' Health Status: ' . strtoupper($status->getHealthStatus()));
|
||||||
|
|
||||||
// Display warnings or recommendations
|
// Display warnings or recommendations
|
||||||
if ($status->isExpired) {
|
if ($status->isExpired) {
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('⚠️ Certificate has expired!');
|
$this->output->writeLine('⚠️ Certificate has expired!');
|
||||||
$this->output->writeln(' Run "ssl:renew" immediately to renew the certificate.');
|
$this->output->writeLine(' Run "ssl:renew" immediately to renew the certificate.');
|
||||||
} elseif ($status->isExpiring) {
|
} elseif ($status->isExpiring) {
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('⚠️ Certificate is expiring soon (< 30 days)');
|
$this->output->writeLine('⚠️ Certificate is expiring soon (< 30 days)');
|
||||||
$this->output->writeln(' Run "ssl:renew" to renew the certificate.');
|
$this->output->writeLine(' Run "ssl:renew" to renew the certificate.');
|
||||||
} elseif (!$status->isValid) {
|
} elseif (!$status->isValid) {
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('⚠️ Certificate is invalid');
|
$this->output->writeLine('⚠️ Certificate is invalid');
|
||||||
if (!empty($status->errors)) {
|
if (!empty($status->errors)) {
|
||||||
$this->output->writeln(' Errors:');
|
$this->output->writeLine(' Errors:');
|
||||||
foreach ($status->errors as $error) {
|
foreach ($status->errors as $error) {
|
||||||
$this->output->writeln(' - ' . $error);
|
$this->output->writeLine(' - ' . $error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('✅ Certificate is valid and healthy');
|
$this->output->writeLine('✅ Certificate is valid and healthy');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
return $status->isValid ? ExitCode::SUCCESS : ExitCode::FAILURE;
|
return $status->isValid ? ExitCode::SUCCESS : ExitCode::FAILURE;
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('❌ Error: ' . $e->getMessage());
|
$this->output->writeLine('❌ Error: ' . $e->getMessage());
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function displayCertificateInfo($status): void
|
private function displayCertificateInfo($status): void
|
||||||
{
|
{
|
||||||
$this->output->writeln('Certificate Information:');
|
$this->output->writeLine('Certificate Information:');
|
||||||
$this->output->writeln('─────────────────────────────────────────');
|
$this->output->writeLine('─────────────────────────────────────────');
|
||||||
|
|
||||||
if ($status->subject) {
|
if ($status->subject) {
|
||||||
$this->output->writeln('Subject: ' . $status->subject);
|
$this->output->writeLine('Subject: ' . $status->subject);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($status->issuer) {
|
if ($status->issuer) {
|
||||||
$this->output->writeln('Issuer: ' . $status->issuer);
|
$this->output->writeLine('Issuer: ' . $status->issuer);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($status->notBefore) {
|
if ($status->notBefore) {
|
||||||
$this->output->writeln('Valid From: ' . $status->notBefore->format('Y-m-d H:i:s T'));
|
$this->output->writeLine('Valid From: ' . $status->notBefore->format('Y-m-d H:i:s T'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($status->notAfter) {
|
if ($status->notAfter) {
|
||||||
$this->output->writeln('Valid Until: ' . $status->notAfter->format('Y-m-d H:i:s T'));
|
$this->output->writeLine('Valid Until: ' . $status->notAfter->format('Y-m-d H:i:s T'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($status->daysUntilExpiry !== null) {
|
if ($status->daysUntilExpiry !== null) {
|
||||||
$expiryColor = $status->isExpiring ? '⚠️ ' : '';
|
$expiryColor = $status->isExpiring ? '⚠️ ' : '';
|
||||||
$this->output->writeln('Days Until Expiry: ' . $expiryColor . $status->daysUntilExpiry . ' days');
|
$this->output->writeLine('Days Until Expiry: ' . $expiryColor . $status->daysUntilExpiry . ' days');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->output->writeln('─────────────────────────────────────────');
|
$this->output->writeLine('─────────────────────────────────────────');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace App\Framework\Deployment\Ssl\Commands;
|
|||||||
|
|
||||||
use App\Framework\Config\Environment;
|
use App\Framework\Config\Environment;
|
||||||
use App\Framework\Console\Attributes\ConsoleCommand;
|
use App\Framework\Console\Attributes\ConsoleCommand;
|
||||||
use App\Framework\Console\ConsoleInput;
|
|
||||||
use App\Framework\Console\ConsoleOutput;
|
use App\Framework\Console\ConsoleOutput;
|
||||||
use App\Framework\Console\ExitCode;
|
use App\Framework\Console\ExitCode;
|
||||||
use App\Framework\Deployment\Ssl\Services\SslCertificateService;
|
use App\Framework\Deployment\Ssl\Services\SslCertificateService;
|
||||||
@@ -27,10 +26,10 @@ final readonly class SslTestCommand
|
|||||||
private ConsoleOutput $output
|
private ConsoleOutput $output
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function execute(ConsoleInput $input): int
|
public function execute(): ExitCode
|
||||||
{
|
{
|
||||||
$this->output->writeln('🧪 Testing SSL Configuration...');
|
$this->output->writeLine('🧪 Testing SSL Configuration...');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load configuration from environment
|
// Load configuration from environment
|
||||||
@@ -40,51 +39,51 @@ final readonly class SslTestCommand
|
|||||||
$this->displayConfiguration($config);
|
$this->displayConfiguration($config);
|
||||||
|
|
||||||
// Run dry-run test
|
// Run dry-run test
|
||||||
$this->output->writeln('Running dry-run test with Let\'s Encrypt...');
|
$this->output->writeLine('Running dry-run test with Let\'s Encrypt...');
|
||||||
$this->output->writeln('This will verify your configuration without obtaining a certificate.');
|
$this->output->writeLine('This will verify your configuration without obtaining a certificate.');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
$success = $this->sslService->test($config);
|
$success = $this->sslService->test($config);
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('✅ Configuration test passed!');
|
$this->output->writeLine('✅ Configuration test passed!');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('Your domain, DNS, and webroot configuration are correct.');
|
$this->output->writeLine('Your domain, DNS, and webroot configuration are correct.');
|
||||||
$this->output->writeln('You can now run "ssl:init" to obtain a real certificate.');
|
$this->output->writeLine('You can now run "ssl:init" to obtain a real certificate.');
|
||||||
} else {
|
} else {
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('❌ Configuration test failed!');
|
$this->output->writeLine('❌ Configuration test failed!');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('Please check:');
|
$this->output->writeLine('Please check:');
|
||||||
$this->output->writeln(' - Domain DNS is correctly configured and pointing to this server');
|
$this->output->writeLine(' - Domain DNS is correctly configured and pointing to this server');
|
||||||
$this->output->writeln(' - Port 80 is open and accessible from the internet');
|
$this->output->writeLine(' - Port 80 is open and accessible from the internet');
|
||||||
$this->output->writeln(' - Webroot directory (' . $config->certbotWwwDir->toString() . ') is accessible');
|
$this->output->writeLine(' - Webroot directory (' . $config->certbotWwwDir->toString() . ') is accessible');
|
||||||
$this->output->writeln(' - No firewall or proxy blocking Let\'s Encrypt validation requests');
|
$this->output->writeLine(' - No firewall or proxy blocking Let\'s Encrypt validation requests');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
|
|
||||||
return $success ? ExitCode::SUCCESS : ExitCode::FAILURE;
|
return $success ? ExitCode::SUCCESS : ExitCode::FAILURE;
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
$this->output->writeln('❌ Error: ' . $e->getMessage());
|
$this->output->writeLine('❌ Error: ' . $e->getMessage());
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function displayConfiguration(SslConfiguration $config): void
|
private function displayConfiguration(SslConfiguration $config): void
|
||||||
{
|
{
|
||||||
$this->output->writeln('Configuration:');
|
$this->output->writeLine('Configuration:');
|
||||||
$this->output->writeln('─────────────────────────────────────────');
|
$this->output->writeLine('─────────────────────────────────────────');
|
||||||
$this->output->writeln('Domain: ' . $config->domain->value);
|
$this->output->writeLine('Domain: ' . $config->domain->value);
|
||||||
$this->output->writeln('Email: ' . $config->email->value);
|
$this->output->writeLine('Email: ' . $config->email->value);
|
||||||
$this->output->writeln('Mode: ' . $config->mode->value);
|
$this->output->writeLine('Mode: ' . $config->mode->value);
|
||||||
$this->output->writeln('Webroot: ' . $config->certbotWwwDir->toString());
|
$this->output->writeLine('Webroot: ' . $config->certbotWwwDir->toString());
|
||||||
$this->output->writeln('Config Dir: ' . $config->certbotConfDir->toString());
|
$this->output->writeLine('Config Dir: ' . $config->certbotConfDir->toString());
|
||||||
$this->output->writeln('─────────────────────────────────────────');
|
$this->output->writeLine('─────────────────────────────────────────');
|
||||||
$this->output->writeln('');
|
$this->output->writeLine('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,12 +56,13 @@ final readonly class ErrorBoundaryMiddleware implements HttpMiddleware
|
|||||||
*/
|
*/
|
||||||
private function createJsonFallbackResponse($request): JsonResponse
|
private function createJsonFallbackResponse($request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$generator = new \App\Framework\Ulid\UlidGenerator();
|
||||||
$errorData = [
|
$errorData = [
|
||||||
'error' => [
|
'error' => [
|
||||||
'code' => 'SERVICE_TEMPORARILY_UNAVAILABLE',
|
'code' => 'SERVICE_TEMPORARILY_UNAVAILABLE',
|
||||||
'message' => 'The service is temporarily unavailable. Please try again later.',
|
'message' => 'The service is temporarily unavailable. Please try again later.',
|
||||||
'timestamp' => date(\DateTimeInterface::ISO8601),
|
'timestamp' => date(\DateTimeInterface::ISO8601),
|
||||||
'request_id' => $request->headers->get('X-Request-ID') ?? uniqid(),
|
'request_id' => $request->headers->get('X-Request-ID') ?? $generator->generate(),
|
||||||
],
|
],
|
||||||
'fallback' => true,
|
'fallback' => true,
|
||||||
];
|
];
|
||||||
@@ -74,12 +75,13 @@ final readonly class ErrorBoundaryMiddleware implements HttpMiddleware
|
|||||||
*/
|
*/
|
||||||
private function createHtmlFallbackResponse($request, MiddlewareContext $context)
|
private function createHtmlFallbackResponse($request, MiddlewareContext $context)
|
||||||
{
|
{
|
||||||
|
$generator = new \App\Framework\Ulid\UlidGenerator();
|
||||||
$fallbackHtml = $this->getFallbackHtmlContent($request);
|
$fallbackHtml = $this->getFallbackHtmlContent($request);
|
||||||
|
|
||||||
return new ViewResult($fallbackHtml, [
|
return new ViewResult($fallbackHtml, [
|
||||||
'request' => $request,
|
'request' => $request,
|
||||||
'timestamp' => date(\DateTimeInterface::ISO8601),
|
'timestamp' => date(\DateTimeInterface::ISO8601),
|
||||||
'request_id' => $request->headers->get('X-Request-ID') ?? uniqid(),
|
'request_id' => $request->headers->get('X-Request-ID') ?? $generator->generate(),
|
||||||
], Status::SERVICE_UNAVAILABLE);
|
], Status::SERVICE_UNAVAILABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ trait AtomicStorageTrait
|
|||||||
{
|
{
|
||||||
public function putAtomic(string $path, string $content): void
|
public function putAtomic(string $path, string $content): void
|
||||||
{
|
{
|
||||||
$tempPath = $path . '.tmp.' . uniqid();
|
$generator = new \App\Framework\Ulid\UlidGenerator();
|
||||||
|
$tempPath = $path . '.tmp.' . $generator->generate();
|
||||||
$this->put($tempPath, $content);
|
$this->put($tempPath, $content);
|
||||||
|
|
||||||
$resolvedPath = $this->resolvePath($path);
|
$resolvedPath = $this->resolvePath($path);
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ final readonly class FilePath implements Stringable
|
|||||||
*/
|
*/
|
||||||
public static function temp(?string $filename = null): self
|
public static function temp(?string $filename = null): self
|
||||||
{
|
{
|
||||||
$filename ??= 'tmp_' . uniqid();
|
$generator = new \App\Framework\Ulid\UlidGenerator();
|
||||||
|
$filename ??= 'tmp_' . $generator->generate();
|
||||||
|
|
||||||
return self::tempDir()->join($filename);
|
return self::tempDir()->join($filename);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ namespace App\Framework\HttpClient;
|
|||||||
final readonly class ClientOptions
|
final readonly class ClientOptions
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public float $timeout = 10.0,
|
public int $timeout = 10,
|
||||||
public float $connectTimeout = 3.0,
|
public int $connectTimeout = 3,
|
||||||
public bool $followRedirects = true,
|
public bool $followRedirects = true,
|
||||||
public int $maxRedirects = 5,
|
public int $maxRedirects = 5,
|
||||||
public bool $verifySsl = true,
|
public bool $verifySsl = true,
|
||||||
@@ -46,7 +46,7 @@ final readonly class ClientOptions
|
|||||||
/**
|
/**
|
||||||
* Factory-Methoden für häufige Konfigurationen
|
* Factory-Methoden für häufige Konfigurationen
|
||||||
*/
|
*/
|
||||||
public static function withTimeout(float $timeout): self
|
public static function withTimeout(int $timeout): self
|
||||||
{
|
{
|
||||||
return new self(timeout: $timeout);
|
return new self(timeout: $timeout);
|
||||||
}
|
}
|
||||||
@@ -87,8 +87,8 @@ final readonly class ClientOptions
|
|||||||
public function merge(ClientOptions $other): self
|
public function merge(ClientOptions $other): self
|
||||||
{
|
{
|
||||||
return new self(
|
return new self(
|
||||||
timeout: $other->timeout !== 10.0 ? $other->timeout : $this->timeout,
|
timeout: $other->timeout !== 10 ? $other->timeout : $this->timeout,
|
||||||
connectTimeout: $other->connectTimeout !== 3.0 ? $other->connectTimeout : $this->connectTimeout,
|
connectTimeout: $other->connectTimeout !== 3 ? $other->connectTimeout : $this->connectTimeout,
|
||||||
followRedirects: $other->followRedirects !== true ? $other->followRedirects : $this->followRedirects,
|
followRedirects: $other->followRedirects !== true ? $other->followRedirects : $this->followRedirects,
|
||||||
maxRedirects: $other->maxRedirects !== 5 ? $other->maxRedirects : $this->maxRedirects,
|
maxRedirects: $other->maxRedirects !== 5 ? $other->maxRedirects : $this->maxRedirects,
|
||||||
verifySsl: $other->verifySsl !== true ? $other->verifySsl : $this->verifySsl,
|
verifySsl: $other->verifySsl !== true ? $other->verifySsl : $this->verifySsl,
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ use App\Framework\HttpClient\Curl\HandleOption;
|
|||||||
final readonly class CurlRequestBuilder
|
final readonly class CurlRequestBuilder
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Build curl options using HandleOption enum
|
* Build curl options using HandleOption enum values (integers)
|
||||||
*
|
*
|
||||||
* @return array<HandleOption, mixed>
|
* @return array<int, mixed>
|
||||||
*/
|
*/
|
||||||
public function buildOptions(ClientRequest $request): array
|
public function buildOptions(ClientRequest $request): array
|
||||||
{
|
{
|
||||||
@@ -23,32 +23,32 @@ final readonly class CurlRequestBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
$options = [
|
$options = [
|
||||||
HandleOption::Url => $url,
|
HandleOption::Url->value => $url,
|
||||||
HandleOption::CustomRequest => $request->method->value,
|
HandleOption::CustomRequest->value => $request->method->value,
|
||||||
HandleOption::ReturnTransfer => true,
|
HandleOption::ReturnTransfer->value => true,
|
||||||
HandleOption::Header => true,
|
HandleOption::Header->value => true,
|
||||||
HandleOption::Timeout => $request->options->timeout,
|
HandleOption::Timeout->value => $request->options->timeout,
|
||||||
HandleOption::ConnectTimeout => $request->options->connectTimeout,
|
HandleOption::ConnectTimeout->value => $request->options->connectTimeout,
|
||||||
HandleOption::FollowLocation => $request->options->followRedirects,
|
HandleOption::FollowLocation->value => $request->options->followRedirects,
|
||||||
HandleOption::MaxRedirs => $request->options->maxRedirects,
|
HandleOption::MaxRedirs->value => $request->options->maxRedirects,
|
||||||
HandleOption::SslVerifyPeer => $request->options->verifySsl,
|
HandleOption::SslVerifyPeer->value => $request->options->verifySsl,
|
||||||
HandleOption::SslVerifyHost => $request->options->verifySsl ? 2 : 0,
|
HandleOption::SslVerifyHost->value => $request->options->verifySsl ? 2 : 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($request->options->userAgent !== null) {
|
if ($request->options->userAgent !== null) {
|
||||||
$options[HandleOption::UserAgent] = $request->options->userAgent;
|
$options[HandleOption::UserAgent->value] = $request->options->userAgent;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->options->proxy !== null) {
|
if ($request->options->proxy !== null) {
|
||||||
$options[HandleOption::Proxy] = $request->options->proxy;
|
$options[HandleOption::Proxy->value] = $request->options->proxy;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->body !== '') {
|
if ($request->body !== '') {
|
||||||
$options[HandleOption::PostFields] = $request->body;
|
$options[HandleOption::PostFields->value] = $request->body;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count($request->headers->all()) > 0) {
|
if (count($request->headers->all()) > 0) {
|
||||||
$options[HandleOption::HttpHeader] = HeaderManipulator::formatForCurl($request->headers);
|
$options[HandleOption::HttpHeader->value] = HeaderManipulator::formatForCurl($request->headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $options;
|
return $options;
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\MachineLearning\Migrations;
|
||||||
|
|
||||||
|
use App\Framework\Database\ConnectionInterface;
|
||||||
|
use App\Framework\Database\Migration\Migration;
|
||||||
|
use App\Framework\Database\Migration\MigrationVersion;
|
||||||
|
use App\Framework\Database\Schema\Blueprint;
|
||||||
|
use App\Framework\Database\Schema\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create ML Confidence Baselines Table
|
||||||
|
*
|
||||||
|
* Stores historical confidence statistics for drift detection:
|
||||||
|
* - Average confidence per model version
|
||||||
|
* - Standard deviation for anomaly detection
|
||||||
|
* - Last update timestamp
|
||||||
|
*
|
||||||
|
* Uses ON CONFLICT for upsert pattern - baselines are updated
|
||||||
|
* as new predictions come in.
|
||||||
|
*/
|
||||||
|
final class CreateMlConfidenceBaselinesTable implements Migration
|
||||||
|
{
|
||||||
|
public function up(ConnectionInterface $connection): void
|
||||||
|
{
|
||||||
|
$schema = new Schema($connection);
|
||||||
|
|
||||||
|
$schema->createIfNotExists('ml_confidence_baselines', function (Blueprint $table) {
|
||||||
|
// Model identification
|
||||||
|
$table->string('model_name', 255);
|
||||||
|
$table->string('version', 50);
|
||||||
|
|
||||||
|
// Confidence statistics
|
||||||
|
$table->decimal('avg_confidence', 5, 4); // Average confidence score
|
||||||
|
$table->decimal('std_dev_confidence', 5, 4); // Standard deviation
|
||||||
|
|
||||||
|
// Tracking
|
||||||
|
$table->timestamp('updated_at')->useCurrent();
|
||||||
|
|
||||||
|
// Primary key: model_name + version (one baseline per model version)
|
||||||
|
$table->primary('model_name', 'version');
|
||||||
|
|
||||||
|
// Index for lookup by model
|
||||||
|
$table->index(['model_name'], 'idx_ml_baselines_model');
|
||||||
|
});
|
||||||
|
|
||||||
|
$schema->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(ConnectionInterface $connection): void
|
||||||
|
{
|
||||||
|
$schema = new Schema($connection);
|
||||||
|
$schema->dropIfExists('ml_confidence_baselines');
|
||||||
|
$schema->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVersion(): MigrationVersion
|
||||||
|
{
|
||||||
|
return MigrationVersion::fromTimestamp("2025_01_26_100002");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return "Create ML confidence baselines table for drift detection";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\MachineLearning\Migrations;
|
||||||
|
|
||||||
|
use App\Framework\Database\ConnectionInterface;
|
||||||
|
use App\Framework\Database\Migration\Migration;
|
||||||
|
use App\Framework\Database\Migration\MigrationVersion;
|
||||||
|
use App\Framework\Database\Schema\Blueprint;
|
||||||
|
use App\Framework\Database\Schema\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create ML Models Table
|
||||||
|
*
|
||||||
|
* Stores machine learning model metadata including:
|
||||||
|
* - Model identification (name, version)
|
||||||
|
* - Model type (supervised, unsupervised, reinforcement)
|
||||||
|
* - Configuration and performance metrics (JSON)
|
||||||
|
* - Deployment status and environment
|
||||||
|
*/
|
||||||
|
final class CreateMlModelsTable implements Migration
|
||||||
|
{
|
||||||
|
public function up(ConnectionInterface $connection): void
|
||||||
|
{
|
||||||
|
$schema = new Schema($connection);
|
||||||
|
|
||||||
|
$schema->createIfNotExists('ml_models', function (Blueprint $table) {
|
||||||
|
// Primary identification
|
||||||
|
$table->string('model_name', 255);
|
||||||
|
$table->string('version', 50);
|
||||||
|
|
||||||
|
// Model metadata
|
||||||
|
$table->string('model_type', 50); // supervised, unsupervised, reinforcement
|
||||||
|
$table->text('configuration'); // JSON: hyperparameters, architecture, etc.
|
||||||
|
$table->text('performance_metrics'); // JSON: accuracy, precision, recall, etc.
|
||||||
|
|
||||||
|
// Deployment tracking
|
||||||
|
$table->boolean('is_deployed')->default(false);
|
||||||
|
$table->string('environment', 50); // development, staging, production
|
||||||
|
|
||||||
|
// Documentation
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
// Primary key: model_name + version
|
||||||
|
$table->primary('model_name', 'version');
|
||||||
|
|
||||||
|
// Indexes for efficient queries
|
||||||
|
$table->index(['model_type'], 'idx_ml_models_type');
|
||||||
|
$table->index(['environment', 'is_deployed'], 'idx_ml_models_env');
|
||||||
|
$table->index(['created_at'], 'idx_ml_models_created');
|
||||||
|
});
|
||||||
|
|
||||||
|
$schema->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(ConnectionInterface $connection): void
|
||||||
|
{
|
||||||
|
$schema = new Schema($connection);
|
||||||
|
$schema->dropIfExists('ml_models');
|
||||||
|
$schema->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVersion(): MigrationVersion
|
||||||
|
{
|
||||||
|
return MigrationVersion::fromTimestamp("2025_01_26_100000");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return "Create ML models metadata table";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\MachineLearning\Migrations;
|
||||||
|
|
||||||
|
use App\Framework\Database\ConnectionInterface;
|
||||||
|
use App\Framework\Database\Migration\Migration;
|
||||||
|
use App\Framework\Database\Migration\MigrationVersion;
|
||||||
|
use App\Framework\Database\Schema\Blueprint;
|
||||||
|
use App\Framework\Database\Schema\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create ML Predictions Table
|
||||||
|
*
|
||||||
|
* Stores individual prediction records for performance tracking:
|
||||||
|
* - Prediction inputs and outputs
|
||||||
|
* - Actual outcomes (when available)
|
||||||
|
* - Confidence scores
|
||||||
|
* - Correctness evaluation
|
||||||
|
*
|
||||||
|
* Performance optimizations:
|
||||||
|
* - Composite index on (model_name, version, timestamp) for time-window queries
|
||||||
|
* - Partitioning-ready for large-scale deployments
|
||||||
|
*/
|
||||||
|
final class CreateMlPredictionsTable implements Migration
|
||||||
|
{
|
||||||
|
public function up(ConnectionInterface $connection): void
|
||||||
|
{
|
||||||
|
$schema = new Schema($connection);
|
||||||
|
|
||||||
|
$schema->createIfNotExists('ml_predictions', function (Blueprint $table) {
|
||||||
|
$table->id(); // Auto-incrementing primary key
|
||||||
|
|
||||||
|
// Model identification
|
||||||
|
$table->string('model_name', 255);
|
||||||
|
$table->string('version', 50);
|
||||||
|
|
||||||
|
// Prediction data (JSON)
|
||||||
|
$table->text('prediction'); // JSON: model output
|
||||||
|
$table->text('actual'); // JSON: actual outcome (when available)
|
||||||
|
$table->text('features'); // JSON: input features
|
||||||
|
|
||||||
|
// Performance metrics
|
||||||
|
$table->decimal('confidence', 5, 4); // 0.0000 to 1.0000
|
||||||
|
$table->boolean('is_correct')->nullable(); // null until actual outcome known
|
||||||
|
|
||||||
|
// Timing
|
||||||
|
$table->timestamp('timestamp')->useCurrent();
|
||||||
|
|
||||||
|
// Composite index for efficient time-window queries
|
||||||
|
$table->index(['model_name', 'version', 'timestamp'], 'idx_ml_predictions_lookup');
|
||||||
|
|
||||||
|
// Index for cleanup operations
|
||||||
|
$table->index(['timestamp'], 'idx_ml_predictions_timestamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
$schema->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(ConnectionInterface $connection): void
|
||||||
|
{
|
||||||
|
$schema = new Schema($connection);
|
||||||
|
$schema->dropIfExists('ml_predictions');
|
||||||
|
$schema->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVersion(): MigrationVersion
|
||||||
|
{
|
||||||
|
return MigrationVersion::fromTimestamp("2025_01_26_100001");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return "Create ML predictions tracking table";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ final readonly class ABTestingService
|
|||||||
public function selectVersion(ABTestConfig $config): Version
|
public function selectVersion(ABTestConfig $config): Version
|
||||||
{
|
{
|
||||||
// Generate random number 0.0-1.0
|
// Generate random number 0.0-1.0
|
||||||
$randomValue = $this->random->float(0.0, 1.0);
|
$randomValue = $this->random->float(0, 1);
|
||||||
|
|
||||||
// If random < trafficSplit, select version A, otherwise B
|
// If random < trafficSplit, select version A, otherwise B
|
||||||
return $randomValue < $config->trafficSplitA
|
return $randomValue < $config->trafficSplitA
|
||||||
|
|||||||
@@ -93,15 +93,27 @@ final readonly class AutoTuningEngine
|
|||||||
|
|
||||||
// Grid search over threshold range
|
// Grid search over threshold range
|
||||||
$results = [];
|
$results = [];
|
||||||
for ($threshold = $thresholdRange[0]; $threshold <= $thresholdRange[1]; $threshold += $step) {
|
$threshold = $thresholdRange[0];
|
||||||
|
while ($threshold <= $thresholdRange[1]) {
|
||||||
$metrics = $this->evaluateThreshold($predictions, $threshold);
|
$metrics = $this->evaluateThreshold($predictions, $threshold);
|
||||||
$results[$threshold] = $metrics[$metricToOptimize] ?? 0.0;
|
$metricValue = $metrics[$metricToOptimize] ?? 0.0;
|
||||||
|
$results[] = [
|
||||||
|
'threshold' => $threshold,
|
||||||
|
'metric_value' => $metricValue,
|
||||||
|
];
|
||||||
|
$threshold += $step;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find optimal threshold
|
// Find optimal threshold (max metric value)
|
||||||
arsort($results);
|
$optimalResult = array_reduce($results, function ($best, $current) {
|
||||||
$optimalThreshold = array_key_first($results);
|
if ($best === null || $current['metric_value'] > $best['metric_value']) {
|
||||||
$optimalMetricValue = $results[$optimalThreshold];
|
return $current;
|
||||||
|
}
|
||||||
|
return $best;
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
$optimalThreshold = $optimalResult['threshold'];
|
||||||
|
$optimalMetricValue = $optimalResult['metric_value'];
|
||||||
|
|
||||||
// Calculate improvement
|
// Calculate improvement
|
||||||
$currentMetrics = $this->evaluateThreshold($predictions, $currentThreshold);
|
$currentMetrics = $this->evaluateThreshold($predictions, $currentThreshold);
|
||||||
@@ -117,13 +129,20 @@ final readonly class AutoTuningEngine
|
|||||||
$currentThreshold
|
$currentThreshold
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Convert results array for output (use string keys to avoid float precision issues)
|
||||||
|
$allResults = [];
|
||||||
|
foreach ($results as $result) {
|
||||||
|
$key = sprintf('%.2f', $result['threshold']);
|
||||||
|
$allResults[$key] = $result['metric_value'];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'optimal_threshold' => $optimalThreshold,
|
'optimal_threshold' => $optimalThreshold,
|
||||||
'optimal_metric_value' => $optimalMetricValue,
|
'optimal_metric_value' => $optimalMetricValue,
|
||||||
'current_threshold' => $currentThreshold,
|
'current_threshold' => $currentThreshold,
|
||||||
'current_metric_value' => $currentMetricValue,
|
'current_metric_value' => $currentMetricValue,
|
||||||
'improvement_percent' => $improvement,
|
'improvement_percent' => $improvement,
|
||||||
'all_results' => $results,
|
'all_results' => $allResults,
|
||||||
'recommendation' => $recommendation,
|
'recommendation' => $recommendation,
|
||||||
'metric_optimized' => $metricToOptimize,
|
'metric_optimized' => $metricToOptimize,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Framework\MachineLearning\ModelManagement\Exceptions\ModelAlreadyExistsE
|
|||||||
use App\Framework\MachineLearning\ModelManagement\Exceptions\ModelNotFoundException;
|
use App\Framework\MachineLearning\ModelManagement\Exceptions\ModelNotFoundException;
|
||||||
use App\Framework\Cache\Cache;
|
use App\Framework\Cache\Cache;
|
||||||
use App\Framework\Cache\CacheKey;
|
use App\Framework\Cache\CacheKey;
|
||||||
|
use App\Framework\Cache\CacheItem;
|
||||||
use App\Framework\Core\ValueObjects\Version;
|
use App\Framework\Core\ValueObjects\Version;
|
||||||
use App\Framework\Core\ValueObjects\Duration;
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
|
||||||
@@ -47,9 +48,11 @@ final readonly class CacheModelRegistry implements ModelRegistry
|
|||||||
|
|
||||||
// Store model metadata
|
// Store model metadata
|
||||||
$this->cache->set(
|
$this->cache->set(
|
||||||
|
CacheItem::forSet(
|
||||||
$modelKey,
|
$modelKey,
|
||||||
$metadata->toArray(),
|
$metadata->toArray(),
|
||||||
Duration::fromDays($this->ttlDays)
|
Duration::fromDays($this->ttlDays)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add to versions list
|
// Add to versions list
|
||||||
@@ -167,9 +170,11 @@ final readonly class CacheModelRegistry implements ModelRegistry
|
|||||||
|
|
||||||
// Update model metadata
|
// Update model metadata
|
||||||
$this->cache->set(
|
$this->cache->set(
|
||||||
|
CacheItem::forSet(
|
||||||
$modelKey,
|
$modelKey,
|
||||||
$metadata->toArray(),
|
$metadata->toArray(),
|
||||||
Duration::fromDays($this->ttlDays)
|
Duration::fromDays($this->ttlDays)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update environment index if deployment changed
|
// Update environment index if deployment changed
|
||||||
@@ -314,9 +319,11 @@ final readonly class CacheModelRegistry implements ModelRegistry
|
|||||||
$versions[] = $versionString;
|
$versions[] = $versionString;
|
||||||
|
|
||||||
$this->cache->set(
|
$this->cache->set(
|
||||||
|
CacheItem::forSet(
|
||||||
$versionsKey,
|
$versionsKey,
|
||||||
$versions,
|
$versions,
|
||||||
Duration::fromDays($this->ttlDays)
|
Duration::fromDays($this->ttlDays)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,9 +343,11 @@ final readonly class CacheModelRegistry implements ModelRegistry
|
|||||||
$this->cache->forget($versionsKey);
|
$this->cache->forget($versionsKey);
|
||||||
} else {
|
} else {
|
||||||
$this->cache->set(
|
$this->cache->set(
|
||||||
|
CacheItem::forSet(
|
||||||
$versionsKey,
|
$versionsKey,
|
||||||
array_values($versions),
|
array_values($versions),
|
||||||
Duration::fromDays($this->ttlDays)
|
Duration::fromDays($this->ttlDays)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,9 +364,11 @@ final readonly class CacheModelRegistry implements ModelRegistry
|
|||||||
$names[] = $modelName;
|
$names[] = $modelName;
|
||||||
|
|
||||||
$this->cache->set(
|
$this->cache->set(
|
||||||
|
CacheItem::forSet(
|
||||||
$namesKey,
|
$namesKey,
|
||||||
$names,
|
$names,
|
||||||
Duration::fromDays($this->ttlDays)
|
Duration::fromDays($this->ttlDays)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,9 +387,11 @@ final readonly class CacheModelRegistry implements ModelRegistry
|
|||||||
$this->cache->forget($namesKey);
|
$this->cache->forget($namesKey);
|
||||||
} else {
|
} else {
|
||||||
$this->cache->set(
|
$this->cache->set(
|
||||||
|
CacheItem::forSet(
|
||||||
$namesKey,
|
$namesKey,
|
||||||
array_values($names),
|
array_values($names),
|
||||||
Duration::fromDays($this->ttlDays)
|
Duration::fromDays($this->ttlDays)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,9 +410,11 @@ final readonly class CacheModelRegistry implements ModelRegistry
|
|||||||
$modelIds[] = $modelId;
|
$modelIds[] = $modelId;
|
||||||
|
|
||||||
$this->cache->set(
|
$this->cache->set(
|
||||||
|
CacheItem::forSet(
|
||||||
$typeKey,
|
$typeKey,
|
||||||
$modelIds,
|
$modelIds,
|
||||||
Duration::fromDays($this->ttlDays)
|
Duration::fromDays($this->ttlDays)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -419,9 +434,11 @@ final readonly class CacheModelRegistry implements ModelRegistry
|
|||||||
$this->cache->forget($typeKey);
|
$this->cache->forget($typeKey);
|
||||||
} else {
|
} else {
|
||||||
$this->cache->set(
|
$this->cache->set(
|
||||||
|
CacheItem::forSet(
|
||||||
$typeKey,
|
$typeKey,
|
||||||
array_values($modelIds),
|
array_values($modelIds),
|
||||||
Duration::fromDays($this->ttlDays)
|
Duration::fromDays($this->ttlDays)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -440,9 +457,11 @@ final readonly class CacheModelRegistry implements ModelRegistry
|
|||||||
$modelIds[] = $modelId;
|
$modelIds[] = $modelId;
|
||||||
|
|
||||||
$this->cache->set(
|
$this->cache->set(
|
||||||
|
CacheItem::forSet(
|
||||||
$envKey,
|
$envKey,
|
||||||
$modelIds,
|
$modelIds,
|
||||||
Duration::fromDays($this->ttlDays)
|
Duration::fromDays($this->ttlDays)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -462,9 +481,11 @@ final readonly class CacheModelRegistry implements ModelRegistry
|
|||||||
$this->cache->forget($envKey);
|
$this->cache->forget($envKey);
|
||||||
} else {
|
} else {
|
||||||
$this->cache->set(
|
$this->cache->set(
|
||||||
|
CacheItem::forSet(
|
||||||
$envKey,
|
$envKey,
|
||||||
array_values($modelIds),
|
array_values($modelIds),
|
||||||
Duration::fromDays($this->ttlDays)
|
Duration::fromDays($this->ttlDays)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ declare(strict_types=1);
|
|||||||
namespace App\Framework\MachineLearning\ModelManagement;
|
namespace App\Framework\MachineLearning\ModelManagement;
|
||||||
|
|
||||||
use App\Framework\Cache\Cache;
|
use App\Framework\Cache\Cache;
|
||||||
|
use App\Framework\Cache\CacheItem;
|
||||||
use App\Framework\Cache\CacheKey;
|
use App\Framework\Cache\CacheKey;
|
||||||
use App\Framework\Core\ValueObjects\Version;
|
use App\Framework\Core\ValueObjects\Version;
|
||||||
use App\Framework\Core\ValueObjects\Duration;
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
use App\Framework\Core\ValueObjects\Timestamp;
|
use App\Framework\Core\ValueObjects\Timestamp;
|
||||||
|
use App\Framework\Ulid\UlidGenerator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache-based Performance Storage
|
* Cache-based Performance Storage
|
||||||
@@ -37,18 +39,16 @@ final readonly class CachePerformanceStorage implements PerformanceStorage
|
|||||||
|
|
||||||
// Create unique key for this prediction
|
// Create unique key for this prediction
|
||||||
$predictionKey = CacheKey::fromString(
|
$predictionKey = CacheKey::fromString(
|
||||||
self::CACHE_PREFIX . ":{$modelName}:{$version}:pred:{$timestamp}:" . uniqid()
|
self::CACHE_PREFIX . ":{$modelName}:{$version}:pred:{$timestamp}:" . uniqid());
|
||||||
);
|
|
||||||
|
// Convert DateTimeImmutable to timestamp for serialization
|
||||||
|
$predictionRecord['timestamp'] = $predictionRecord['timestamp']->getTimestamp();
|
||||||
|
|
||||||
// Store prediction
|
// Store prediction
|
||||||
$this->cache->set(
|
$this->cache->set(CacheItem::forSet($predictionKey, $predictionRecord, Duration::fromDays($this->ttlDays)));
|
||||||
$predictionKey,
|
|
||||||
$predictionRecord,
|
|
||||||
Duration::fromDays($this->ttlDays)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add to predictions index
|
// Add to predictions index
|
||||||
$this->addToPredictionsIndex($modelName, $version, $predictionKey->key);
|
$this->addToPredictionsIndex($modelName, $version, $predictionKey->toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPredictions(
|
public function getPredictions(
|
||||||
@@ -57,23 +57,31 @@ final readonly class CachePerformanceStorage implements PerformanceStorage
|
|||||||
Duration $timeWindow
|
Duration $timeWindow
|
||||||
): array {
|
): array {
|
||||||
$indexKey = $this->getPredictionsIndexKey($modelName, $version);
|
$indexKey = $this->getPredictionsIndexKey($modelName, $version);
|
||||||
$predictionKeys = $this->cache->get($indexKey) ?? [];
|
$result = $this->cache->get($indexKey);
|
||||||
|
$predictionKeys = $result->value ?? [];
|
||||||
|
|
||||||
if (empty($predictionKeys)) {
|
if (empty($predictionKeys)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$cutoffTimestamp = Timestamp::now()->subtract($timeWindow)->getTimestamp();
|
$cutoffTimestamp = Timestamp::now()->subtract($timeWindow)->toTimestamp();
|
||||||
$predictions = [];
|
$predictions = [];
|
||||||
|
|
||||||
foreach ($predictionKeys as $keyString) {
|
foreach ($predictionKeys as $keyString) {
|
||||||
$predictionKey = CacheKey::fromString($keyString);
|
$predictionKey = CacheKey::fromString($keyString);
|
||||||
$prediction = $this->cache->get($predictionKey);
|
$result = $this->cache->get($predictionKey);
|
||||||
|
|
||||||
|
$prediction = $result->value;
|
||||||
|
|
||||||
if ($prediction === null) {
|
if ($prediction === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert timestamp back to DateTimeImmutable
|
||||||
|
if (is_int($prediction['timestamp'])) {
|
||||||
|
$prediction['timestamp'] = (new \DateTimeImmutable())->setTimestamp($prediction['timestamp']);
|
||||||
|
}
|
||||||
|
|
||||||
// Filter by time window
|
// Filter by time window
|
||||||
if ($prediction['timestamp']->getTimestamp() >= $cutoffTimestamp) {
|
if ($prediction['timestamp']->getTimestamp() >= $cutoffTimestamp) {
|
||||||
$predictions[] = $prediction;
|
$predictions[] = $prediction;
|
||||||
@@ -91,7 +99,10 @@ final readonly class CachePerformanceStorage implements PerformanceStorage
|
|||||||
self::CACHE_PREFIX . ":{$modelName}:{$version->toString()}:baseline"
|
self::CACHE_PREFIX . ":{$modelName}:{$version->toString()}:baseline"
|
||||||
);
|
);
|
||||||
|
|
||||||
$baseline = $this->cache->get($baselineKey);
|
$result = $this->cache->get($baselineKey);
|
||||||
|
|
||||||
|
|
||||||
|
$baseline = $result->value;
|
||||||
|
|
||||||
return $baseline['avg_confidence'] ?? null;
|
return $baseline['avg_confidence'] ?? null;
|
||||||
}
|
}
|
||||||
@@ -112,11 +123,7 @@ final readonly class CachePerformanceStorage implements PerformanceStorage
|
|||||||
'stored_at' => Timestamp::now()->toDateTime(),
|
'stored_at' => Timestamp::now()->toDateTime(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->cache->set(
|
$this->cache->set(CacheItem::forSet($baselineKey, $baseline, Duration::fromDays($this->ttlDays)));
|
||||||
$baselineKey,
|
|
||||||
$baseline,
|
|
||||||
Duration::fromDays($this->ttlDays)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clearOldPredictions(Duration $olderThan): int
|
public function clearOldPredictions(Duration $olderThan): int
|
||||||
@@ -125,14 +132,17 @@ final readonly class CachePerformanceStorage implements PerformanceStorage
|
|||||||
$allIndexKeys = $this->getAllPredictionIndexKeys();
|
$allIndexKeys = $this->getAllPredictionIndexKeys();
|
||||||
|
|
||||||
$deletedCount = 0;
|
$deletedCount = 0;
|
||||||
$cutoffTimestamp = Timestamp::now()->subtract($olderThan)->getTimestamp();
|
$cutoffTimestamp = Timestamp::now()->subtract($olderThan)->toTimestamp();
|
||||||
|
|
||||||
foreach ($allIndexKeys as $indexKey) {
|
foreach ($allIndexKeys as $indexKey) {
|
||||||
$predictionKeys = $this->cache->get($indexKey) ?? [];
|
$result = $this->cache->get($indexKey);
|
||||||
|
$predictionKeys = $result->value ?? [];
|
||||||
|
|
||||||
foreach ($predictionKeys as $i => $keyString) {
|
foreach ($predictionKeys as $i => $keyString) {
|
||||||
$predictionKey = CacheKey::fromString($keyString);
|
$predictionKey = CacheKey::fromString($keyString);
|
||||||
$prediction = $this->cache->get($predictionKey);
|
$result = $this->cache->get($predictionKey);
|
||||||
|
|
||||||
|
$prediction = $result->value;
|
||||||
|
|
||||||
if ($prediction === null) {
|
if ($prediction === null) {
|
||||||
// Already deleted
|
// Already deleted
|
||||||
@@ -140,6 +150,11 @@ final readonly class CachePerformanceStorage implements PerformanceStorage
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert timestamp back to DateTimeImmutable
|
||||||
|
if (is_int($prediction['timestamp'])) {
|
||||||
|
$prediction['timestamp'] = (new \DateTimeImmutable())->setTimestamp($prediction['timestamp']);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete if older than cutoff
|
// Delete if older than cutoff
|
||||||
if ($prediction['timestamp']->getTimestamp() < $cutoffTimestamp) {
|
if ($prediction['timestamp']->getTimestamp() < $cutoffTimestamp) {
|
||||||
$this->cache->forget($predictionKey);
|
$this->cache->forget($predictionKey);
|
||||||
@@ -152,11 +167,7 @@ final readonly class CachePerformanceStorage implements PerformanceStorage
|
|||||||
if (empty($predictionKeys)) {
|
if (empty($predictionKeys)) {
|
||||||
$this->cache->forget($indexKey);
|
$this->cache->forget($indexKey);
|
||||||
} else {
|
} else {
|
||||||
$this->cache->set(
|
$this->cache->set(CacheItem::forSet($indexKey, array_values($predictionKeys), Duration::fromDays($this->ttlDays)));
|
||||||
$indexKey,
|
|
||||||
array_values($predictionKeys),
|
|
||||||
Duration::fromDays($this->ttlDays)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,15 +183,12 @@ final readonly class CachePerformanceStorage implements PerformanceStorage
|
|||||||
string $predictionKey
|
string $predictionKey
|
||||||
): void {
|
): void {
|
||||||
$indexKey = $this->getPredictionsIndexKey($modelName, Version::fromString($version));
|
$indexKey = $this->getPredictionsIndexKey($modelName, Version::fromString($version));
|
||||||
$predictionKeys = $this->cache->get($indexKey) ?? [];
|
$result = $this->cache->get($indexKey);
|
||||||
|
$predictionKeys = $result->value ?? [];
|
||||||
|
|
||||||
$predictionKeys[] = $predictionKey;
|
$predictionKeys[] = $predictionKey;
|
||||||
|
|
||||||
$this->cache->set(
|
$this->cache->set(CacheItem::forSet($indexKey, $predictionKeys, Duration::fromDays($this->ttlDays)));
|
||||||
$indexKey,
|
|
||||||
$predictionKeys,
|
|
||||||
Duration::fromDays($this->ttlDays)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\MachineLearning\ModelManagement;
|
||||||
|
|
||||||
|
use App\Framework\Database\ValueObjects\SqlQuery;
|
||||||
|
use App\Framework\DI\Attributes\DefaultImplementation;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ValueObjects\ModelMetadata;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ValueObjects\ModelType;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\Exceptions\ModelAlreadyExistsException;
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\Exceptions\ModelNotFoundException;
|
||||||
|
use App\Framework\Database\ConnectionInterface;
|
||||||
|
use App\Framework\Core\ValueObjects\Version;
|
||||||
|
use App\Framework\Core\ValueObjects\Timestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database Model Registry - Database-backed ML Model Storage
|
||||||
|
*
|
||||||
|
* Stores model metadata in PostgreSQL/MySQL with the following schema:
|
||||||
|
* - ml_models: Main model metadata table
|
||||||
|
* - Indexed by: model_name, version, type, environment, created_at
|
||||||
|
*
|
||||||
|
* Performance:
|
||||||
|
* - Sub-10ms lookups via indexed queries
|
||||||
|
* - Transaction support for atomic operations
|
||||||
|
* - Optimized for read-heavy workloads
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```php
|
||||||
|
* $registry = new DatabaseModelRegistry($connection);
|
||||||
|
* $registry->register($metadata);
|
||||||
|
* $model = $registry->getLatest('fraud-detector');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
#[DefaultImplementation(ModelRegistry::class)]
|
||||||
|
final readonly class DatabaseModelRegistry implements ModelRegistry
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ConnectionInterface $connection
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function register(ModelMetadata $metadata): void
|
||||||
|
{
|
||||||
|
// Check if model already exists
|
||||||
|
if ($this->exists($metadata->modelName, $metadata->version)) {
|
||||||
|
throw ModelAlreadyExistsException::forModel($metadata->getModelId());
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
INSERT INTO ml_models (
|
||||||
|
model_name, version, model_type, configuration,
|
||||||
|
performance_metrics, created_at, is_deployed,
|
||||||
|
environment, description
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
SQL,
|
||||||
|
parameters: [
|
||||||
|
$metadata->modelName,
|
||||||
|
$metadata->version->toString(),
|
||||||
|
$metadata->modelType->value,
|
||||||
|
json_encode($metadata->configuration),
|
||||||
|
json_encode($metadata->performanceMetrics),
|
||||||
|
$metadata->createdAt->format('Y-m-d H:i:s'),
|
||||||
|
$metadata->isDeployed() ? 1 : 0, // Explicit boolean conversion
|
||||||
|
$metadata->environment ?? '', // Ensure string, not null
|
||||||
|
$metadata->metadata['description'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->connection->execute($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $modelName, Version $version): ?ModelMetadata
|
||||||
|
{
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
SELECT * FROM ml_models
|
||||||
|
WHERE model_name = ? AND version = ?
|
||||||
|
LIMIT 1
|
||||||
|
SQL,
|
||||||
|
parameters: [$modelName, $version->toString()]
|
||||||
|
);
|
||||||
|
|
||||||
|
$row = $this->connection->queryOne($query);
|
||||||
|
|
||||||
|
return $row ? $this->hydrateModel($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLatest(string $modelName): ?ModelMetadata
|
||||||
|
{
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
SELECT * FROM ml_models
|
||||||
|
WHERE model_name = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
SQL,
|
||||||
|
parameters: [$modelName]
|
||||||
|
);
|
||||||
|
|
||||||
|
$row = $this->connection->queryOne($query);
|
||||||
|
|
||||||
|
return $row ? $this->hydrateModel($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAll(string $modelName): array
|
||||||
|
{
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
SELECT * FROM ml_models
|
||||||
|
WHERE model_name = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
SQL,
|
||||||
|
parameters: [$modelName]
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = $this->connection->query($query)->fetchAll();
|
||||||
|
|
||||||
|
return array_map(fn($row) => $this->hydrateModel($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getByType(ModelType $type): array
|
||||||
|
{
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
SELECT * FROM ml_models
|
||||||
|
WHERE model_type = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
SQL,
|
||||||
|
parameters: [$type->value]
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = $this->connection->query($query)->fetchAll();
|
||||||
|
|
||||||
|
return array_map(fn($row) => $this->hydrateModel($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getByEnvironment(string $environment): array
|
||||||
|
{
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
SELECT * FROM ml_models
|
||||||
|
WHERE is_deployed = ? AND environment = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
SQL,
|
||||||
|
parameters: [true, $environment]
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = $this->connection->query($query)->fetchAll();
|
||||||
|
|
||||||
|
return array_map(fn($row) => $this->hydrateModel($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProductionModels(): array
|
||||||
|
{
|
||||||
|
return $this->getByEnvironment('production');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(ModelMetadata $metadata): void
|
||||||
|
{
|
||||||
|
// Check if model exists
|
||||||
|
if (!$this->exists($metadata->modelName, $metadata->version)) {
|
||||||
|
throw new ModelNotFoundException(
|
||||||
|
"Model '{$metadata->getModelId()}' not found in registry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
UPDATE ml_models SET
|
||||||
|
configuration = ?,
|
||||||
|
performance_metrics = ?,
|
||||||
|
is_deployed = ?,
|
||||||
|
environment = ?,
|
||||||
|
description = ?
|
||||||
|
WHERE model_name = ? AND version = ?
|
||||||
|
SQL,
|
||||||
|
parameters: [
|
||||||
|
json_encode($metadata->configuration),
|
||||||
|
json_encode($metadata->performanceMetrics),
|
||||||
|
$metadata->isDeployed() ? 1 : 0, // Explicit boolean conversion
|
||||||
|
$metadata->environment ?? '', // Ensure string, not null
|
||||||
|
$metadata->metadata['description'] ?? null,
|
||||||
|
$metadata->modelName,
|
||||||
|
$metadata->version->toString(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->connection->execute($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $modelName, Version $version): bool
|
||||||
|
{
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
DELETE FROM ml_models
|
||||||
|
WHERE model_name = ? AND version = ?
|
||||||
|
SQL,
|
||||||
|
parameters: [$modelName, $version->toString()]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->connection->execute($query) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $modelName, Version $version): bool
|
||||||
|
{
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
SELECT COUNT(*) as count FROM ml_models
|
||||||
|
WHERE model_name = ? AND version = ?
|
||||||
|
SQL,
|
||||||
|
parameters: [$modelName, $version->toString()]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int) $this->connection->queryScalar($query) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllModelNames(): array
|
||||||
|
{
|
||||||
|
$query = SqlQuery::select('ml_models', ['DISTINCT model_name']);
|
||||||
|
|
||||||
|
$rows = $this->connection->query($query)->fetchAll();
|
||||||
|
|
||||||
|
return array_column($rows, 'model_name');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVersionCount(string $modelName): int
|
||||||
|
{
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
SELECT COUNT(*) as count FROM ml_models
|
||||||
|
WHERE model_name = ?
|
||||||
|
SQL,
|
||||||
|
parameters: [$modelName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int) $this->connection->queryScalar($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTotalCount(): int
|
||||||
|
{
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: 'SELECT COUNT(*) as count FROM ml_models',
|
||||||
|
parameters: []
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int) $this->connection->queryScalar($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: 'DELETE FROM ml_models',
|
||||||
|
parameters: []
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->connection->execute($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate ModelMetadata from database row
|
||||||
|
*/
|
||||||
|
private function hydrateModel(array $row): ModelMetadata
|
||||||
|
{
|
||||||
|
return new ModelMetadata(
|
||||||
|
modelName : $row['model_name'],
|
||||||
|
modelType : ModelType::from($row['model_type']),
|
||||||
|
version : Version::fromString($row['version']),
|
||||||
|
configuration : json_decode($row['configuration'], true) ?? [],
|
||||||
|
performanceMetrics: json_decode($row['performance_metrics'], true) ?? [],
|
||||||
|
createdAt : Timestamp::fromDateTime(new \DateTimeImmutable($row['created_at'])),
|
||||||
|
deployedAt : $row['is_deployed'] && !empty($row['created_at'])
|
||||||
|
? Timestamp::fromDateTime(new \DateTimeImmutable($row['created_at']))
|
||||||
|
: null,
|
||||||
|
environment : $row['environment'],
|
||||||
|
metadata : $row['description'] ? ['description' => $row['description']] : []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\MachineLearning\ModelManagement;
|
||||||
|
|
||||||
|
use App\Framework\Core\ValueObjects\Version;
|
||||||
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
use App\Framework\Database\ConnectionInterface;
|
||||||
|
use App\Framework\Database\ValueObjects\SqlQuery;
|
||||||
|
use App\Framework\Attributes\DefaultImplementation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database Performance Storage Implementation
|
||||||
|
*
|
||||||
|
* Stores performance tracking data in PostgreSQL/MySQL tables:
|
||||||
|
* - ml_predictions: Individual prediction records
|
||||||
|
* - ml_confidence_baselines: Historical confidence baselines
|
||||||
|
*
|
||||||
|
* Performance Characteristics:
|
||||||
|
* - Batch inserts for high throughput (1000+ predictions/sec)
|
||||||
|
* - Time-based partitioning for efficient queries
|
||||||
|
* - Automatic cleanup of old predictions
|
||||||
|
* - Index optimization for time-window queries
|
||||||
|
*
|
||||||
|
* Storage Strategy:
|
||||||
|
* - Recent predictions (7 days): Full storage
|
||||||
|
* - Historical data (30 days): Aggregated metrics
|
||||||
|
* - Old data (>30 days): Automatic cleanup
|
||||||
|
*/
|
||||||
|
#[DefaultImplementation(PerformanceStorage::class)]
|
||||||
|
final readonly class DatabasePerformanceStorage implements PerformanceStorage
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ConnectionInterface $connection
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a prediction record
|
||||||
|
*/
|
||||||
|
public function storePrediction(array $predictionRecord): void
|
||||||
|
{
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
INSERT INTO ml_predictions (
|
||||||
|
model_name, version, prediction, actual,
|
||||||
|
confidence, features, timestamp, is_correct
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
SQL,
|
||||||
|
parameters: [
|
||||||
|
$predictionRecord['model_name'],
|
||||||
|
$predictionRecord['version'],
|
||||||
|
json_encode($predictionRecord['prediction']),
|
||||||
|
json_encode($predictionRecord['actual']),
|
||||||
|
$predictionRecord['confidence'],
|
||||||
|
json_encode($predictionRecord['features']),
|
||||||
|
$predictionRecord['timestamp']->format('Y-m-d H:i:s'),
|
||||||
|
isset($predictionRecord['is_correct']) && $predictionRecord['is_correct'] !== ''
|
||||||
|
? ($predictionRecord['is_correct'] ? 1 : 0)
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->connection->execute($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get predictions within time window
|
||||||
|
*/
|
||||||
|
public function getPredictions(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
Duration $timeWindow
|
||||||
|
): array {
|
||||||
|
$cutoffTime = (new \DateTimeImmutable())->sub(
|
||||||
|
new \DateInterval('PT' . $timeWindow->toSeconds() . 'S')
|
||||||
|
);
|
||||||
|
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
SELECT * FROM ml_predictions
|
||||||
|
WHERE model_name = ?
|
||||||
|
AND version = ?
|
||||||
|
AND timestamp >= ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
SQL,
|
||||||
|
parameters: [
|
||||||
|
$modelName,
|
||||||
|
$version->toString(),
|
||||||
|
$cutoffTime->format('Y-m-d H:i:s'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = $this->connection->query($query)->fetchAll();
|
||||||
|
|
||||||
|
return array_map(fn($row) => $this->hydratePrediction($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historical average confidence for baseline
|
||||||
|
*/
|
||||||
|
public function getHistoricalAverageConfidence(
|
||||||
|
string $modelName,
|
||||||
|
Version $version
|
||||||
|
): ?float {
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
SELECT avg_confidence FROM ml_confidence_baselines
|
||||||
|
WHERE model_name = ? AND version = ?
|
||||||
|
LIMIT 1
|
||||||
|
SQL,
|
||||||
|
parameters: [$modelName, $version->toString()]
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->connection->queryScalar($query);
|
||||||
|
|
||||||
|
return $result !== null ? (float) $result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store confidence baseline for drift detection
|
||||||
|
*/
|
||||||
|
public function storeConfidenceBaseline(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
float $avgConfidence,
|
||||||
|
float $stdDevConfidence
|
||||||
|
): void {
|
||||||
|
// Check if baseline exists
|
||||||
|
$existing = $this->getConfidenceBaseline($modelName, $version);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
// Update existing baseline
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
UPDATE ml_confidence_baselines
|
||||||
|
SET avg_confidence = ?, std_dev_confidence = ?, updated_at = ?
|
||||||
|
WHERE model_name = ? AND version = ?
|
||||||
|
SQL,
|
||||||
|
parameters: [
|
||||||
|
$avgConfidence,
|
||||||
|
$stdDevConfidence,
|
||||||
|
(new \DateTimeImmutable())->format('Y-m-d H:i:s'),
|
||||||
|
$modelName,
|
||||||
|
$version->toString(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Insert new baseline
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
INSERT INTO ml_confidence_baselines (
|
||||||
|
model_name, version, avg_confidence, std_dev_confidence,
|
||||||
|
updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
SQL,
|
||||||
|
parameters: [
|
||||||
|
$modelName,
|
||||||
|
$version->toString(),
|
||||||
|
$avgConfidence,
|
||||||
|
$stdDevConfidence,
|
||||||
|
(new \DateTimeImmutable())->format('Y-m-d H:i:s'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->connection->execute($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear old prediction records (cleanup)
|
||||||
|
*/
|
||||||
|
public function clearOldPredictions(Duration $olderThan): int
|
||||||
|
{
|
||||||
|
$cutoffTime = (new \DateTimeImmutable())->sub(
|
||||||
|
new \DateInterval('PT' . $olderThan->toSeconds() . 'S')
|
||||||
|
);
|
||||||
|
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
DELETE FROM ml_predictions
|
||||||
|
WHERE timestamp < ?
|
||||||
|
SQL,
|
||||||
|
parameters: [$cutoffTime->format('Y-m-d H:i:s')]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->connection->execute($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get prediction count for a model within time window
|
||||||
|
*/
|
||||||
|
public function getPredictionCount(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
Duration $timeWindow
|
||||||
|
): int {
|
||||||
|
$cutoffTime = (new \DateTimeImmutable())->sub(
|
||||||
|
new \DateInterval('PT' . $timeWindow->toSeconds() . 'S')
|
||||||
|
);
|
||||||
|
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
SELECT COUNT(*) as count FROM ml_predictions
|
||||||
|
WHERE model_name = ?
|
||||||
|
AND version = ?
|
||||||
|
AND timestamp >= ?
|
||||||
|
SQL,
|
||||||
|
parameters: [
|
||||||
|
$modelName,
|
||||||
|
$version->toString(),
|
||||||
|
$cutoffTime->format('Y-m-d H:i:s'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (int) $this->connection->queryScalar($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get aggregated metrics for a model within time window
|
||||||
|
*/
|
||||||
|
public function getAggregatedMetrics(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
Duration $timeWindow
|
||||||
|
): array {
|
||||||
|
$cutoffTime = (new \DateTimeImmutable())->sub(
|
||||||
|
new \DateInterval('PT' . $timeWindow->toSeconds() . 'S')
|
||||||
|
);
|
||||||
|
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_predictions,
|
||||||
|
AVG(confidence) as avg_confidence,
|
||||||
|
MIN(confidence) as min_confidence,
|
||||||
|
MAX(confidence) as max_confidence,
|
||||||
|
SUM(CASE WHEN is_correct = true THEN 1 ELSE 0 END) as correct_predictions
|
||||||
|
FROM ml_predictions
|
||||||
|
WHERE model_name = ?
|
||||||
|
AND version = ?
|
||||||
|
AND timestamp >= ?
|
||||||
|
AND is_correct IS NOT NULL
|
||||||
|
SQL,
|
||||||
|
parameters: [
|
||||||
|
$modelName,
|
||||||
|
$version->toString(),
|
||||||
|
$cutoffTime->format('Y-m-d H:i:s'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$row = $this->connection->queryOne($query);
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
return [
|
||||||
|
'total_predictions' => 0,
|
||||||
|
'avg_confidence' => 0.0,
|
||||||
|
'min_confidence' => 0.0,
|
||||||
|
'max_confidence' => 0.0,
|
||||||
|
'correct_predictions' => 0,
|
||||||
|
'accuracy' => 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (int) $row['total_predictions'];
|
||||||
|
$correct = (int) $row['correct_predictions'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_predictions' => $total,
|
||||||
|
'avg_confidence' => (float) $row['avg_confidence'],
|
||||||
|
'min_confidence' => (float) $row['min_confidence'],
|
||||||
|
'max_confidence' => (float) $row['max_confidence'],
|
||||||
|
'correct_predictions' => $correct,
|
||||||
|
'accuracy' => $total > 0 ? $correct / $total : 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent predictions (limit-based)
|
||||||
|
*/
|
||||||
|
public function getRecentPredictions(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
int $limit
|
||||||
|
): array {
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
SELECT * FROM ml_predictions
|
||||||
|
WHERE model_name = ? AND version = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
SQL,
|
||||||
|
parameters: [
|
||||||
|
$modelName,
|
||||||
|
$version->toString(),
|
||||||
|
$limit
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = $this->connection->query($query)->fetchAll();
|
||||||
|
|
||||||
|
return array_map(fn($row) => $this->hydratePrediction($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate accuracy from recent predictions
|
||||||
|
*/
|
||||||
|
public function calculateAccuracy(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
int $limit
|
||||||
|
): float {
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN is_correct = true THEN 1 ELSE 0 END) as correct
|
||||||
|
FROM (
|
||||||
|
SELECT is_correct FROM ml_predictions
|
||||||
|
WHERE model_name = ? AND version = ? AND is_correct IS NOT NULL
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
) recent
|
||||||
|
SQL,
|
||||||
|
parameters: [
|
||||||
|
$modelName,
|
||||||
|
$version->toString(),
|
||||||
|
$limit
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$row = $this->connection->queryOne($query);
|
||||||
|
|
||||||
|
if (!$row || (int) $row['total'] === 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (float) $row['correct'] / (float) $row['total'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get confidence baseline
|
||||||
|
*/
|
||||||
|
public function getConfidenceBaseline(
|
||||||
|
string $modelName,
|
||||||
|
Version $version
|
||||||
|
): ?array {
|
||||||
|
$query = SqlQuery::create(
|
||||||
|
sql: <<<'SQL'
|
||||||
|
SELECT avg_confidence, std_dev_confidence
|
||||||
|
FROM ml_confidence_baselines
|
||||||
|
WHERE model_name = ? AND version = ?
|
||||||
|
LIMIT 1
|
||||||
|
SQL,
|
||||||
|
parameters: [$modelName, $version->toString()]
|
||||||
|
);
|
||||||
|
|
||||||
|
$row = $this->connection->queryOne($query);
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'avg_confidence' => (float) $row['avg_confidence'],
|
||||||
|
'std_dev_confidence' => (float) $row['std_dev_confidence']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate prediction from database row
|
||||||
|
*/
|
||||||
|
private function hydratePrediction(array $row): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'model_name' => $row['model_name'],
|
||||||
|
'version' => $row['version'],
|
||||||
|
'prediction' => json_decode($row['prediction'], true),
|
||||||
|
'actual' => json_decode($row['actual'], true),
|
||||||
|
'confidence' => (float) $row['confidence'],
|
||||||
|
'features' => json_decode($row['features'], true),
|
||||||
|
'timestamp' => new \DateTimeImmutable($row['timestamp']),
|
||||||
|
'is_correct' => $row['is_correct'] !== null ? (bool) $row['is_correct'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Framework\MachineLearning\ModelManagement\Exceptions;
|
namespace App\Framework\MachineLearning\ModelManagement\Exceptions;
|
||||||
|
|
||||||
use App\Framework\Exception\FrameworkException;
|
use App\Framework\Exception\FrameworkException;
|
||||||
use App\Framework\Exception\Core\ErrorCode;
|
use App\Framework\Exception\Core\ValidationErrorCode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Model Already Exists Exception
|
* Model Already Exists Exception
|
||||||
@@ -17,7 +17,7 @@ final class ModelAlreadyExistsException extends FrameworkException
|
|||||||
public static function forModel(string $modelId): self
|
public static function forModel(string $modelId): self
|
||||||
{
|
{
|
||||||
return self::create(
|
return self::create(
|
||||||
ErrorCode::DUPLICATE_ENTRY,
|
ValidationErrorCode::DUPLICATE_VALUE,
|
||||||
"Model '{$modelId}' already exists in registry"
|
"Model '{$modelId}' already exists in registry"
|
||||||
)->withData([
|
)->withData([
|
||||||
'model_id' => $modelId,
|
'model_id' => $modelId,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Framework\MachineLearning\ModelManagement\Exceptions;
|
namespace App\Framework\MachineLearning\ModelManagement\Exceptions;
|
||||||
|
|
||||||
use App\Framework\Exception\FrameworkException;
|
use App\Framework\Exception\FrameworkException;
|
||||||
use App\Framework\Exception\Core\ErrorCode;
|
use App\Framework\Exception\Core\EntityErrorCode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Model Not Found Exception
|
* Model Not Found Exception
|
||||||
@@ -17,7 +17,7 @@ final class ModelNotFoundException extends FrameworkException
|
|||||||
public static function forModel(string $modelId): self
|
public static function forModel(string $modelId): self
|
||||||
{
|
{
|
||||||
return self::create(
|
return self::create(
|
||||||
ErrorCode::NOT_FOUND,
|
EntityErrorCode::ENTITY_NOT_FOUND,
|
||||||
"Model '{$modelId}' not found in registry"
|
"Model '{$modelId}' not found in registry"
|
||||||
)->withData([
|
)->withData([
|
||||||
'model_id' => $modelId,
|
'model_id' => $modelId,
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\MachineLearning\ModelManagement;
|
||||||
|
|
||||||
|
use App\Framework\Core\ValueObjects\Version;
|
||||||
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-Memory Performance Storage Implementation
|
||||||
|
*
|
||||||
|
* Stores performance tracking data in memory for testing.
|
||||||
|
*/
|
||||||
|
final class InMemoryPerformanceStorage implements PerformanceStorage
|
||||||
|
{
|
||||||
|
/** @var array<array> */
|
||||||
|
private array $predictions = [];
|
||||||
|
|
||||||
|
/** @var array<string, array{avg: float, stdDev: float}> */
|
||||||
|
private array $confidenceBaselines = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a prediction record
|
||||||
|
*/
|
||||||
|
public function storePrediction(array $predictionRecord): void
|
||||||
|
{
|
||||||
|
$this->predictions[] = $predictionRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get predictions within time window
|
||||||
|
*/
|
||||||
|
public function getPredictions(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
Duration $timeWindow
|
||||||
|
): array {
|
||||||
|
$cutoffTime = (new \DateTimeImmutable())->sub(
|
||||||
|
new \DateInterval('PT' . $timeWindow->toSeconds() . 'S')
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->predictions,
|
||||||
|
fn($record) =>
|
||||||
|
$record['model_name'] === $modelName
|
||||||
|
&& $record['version'] === $version->toString()
|
||||||
|
&& $record['timestamp'] >= $cutoffTime
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historical average confidence for baseline
|
||||||
|
*/
|
||||||
|
public function getHistoricalAverageConfidence(
|
||||||
|
string $modelName,
|
||||||
|
Version $version
|
||||||
|
): ?float {
|
||||||
|
$key = $this->getBaselineKey($modelName, $version);
|
||||||
|
|
||||||
|
return $this->confidenceBaselines[$key]['avg'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store confidence baseline for drift detection
|
||||||
|
*/
|
||||||
|
public function storeConfidenceBaseline(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
float $avgConfidence,
|
||||||
|
float $stdDevConfidence
|
||||||
|
): void {
|
||||||
|
$key = $this->getBaselineKey($modelName, $version);
|
||||||
|
|
||||||
|
$this->confidenceBaselines[$key] = [
|
||||||
|
'avg' => $avgConfidence,
|
||||||
|
'stdDev' => $stdDevConfidence
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear old prediction records (cleanup)
|
||||||
|
*/
|
||||||
|
public function clearOldPredictions(Duration $olderThan): int
|
||||||
|
{
|
||||||
|
$cutoffTime = (new \DateTimeImmutable())->sub(
|
||||||
|
new \DateInterval('PT' . $olderThan->toSeconds() . 'S')
|
||||||
|
);
|
||||||
|
|
||||||
|
$initialCount = count($this->predictions);
|
||||||
|
|
||||||
|
$this->predictions = array_values(array_filter(
|
||||||
|
$this->predictions,
|
||||||
|
fn($record) => $record['timestamp'] >= $cutoffTime
|
||||||
|
));
|
||||||
|
|
||||||
|
return $initialCount - count($this->predictions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get baseline key for confidence storage
|
||||||
|
*/
|
||||||
|
private function getBaselineKey(string $modelName, Version $version): string
|
||||||
|
{
|
||||||
|
return "{$modelName}:{$version->toString()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all stored predictions (for testing)
|
||||||
|
*/
|
||||||
|
public function getAllPredictions(): array
|
||||||
|
{
|
||||||
|
return $this->predictions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all data (for testing)
|
||||||
|
*/
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$this->predictions = [];
|
||||||
|
$this->confidenceBaselines = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
162
src/Framework/MachineLearning/ModelManagement/MLConfig.php
Normal file
162
src/Framework/MachineLearning/ModelManagement/MLConfig.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\MachineLearning\ModelManagement;
|
||||||
|
|
||||||
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ML Model Management Configuration
|
||||||
|
*
|
||||||
|
* Typsichere Konfiguration für das ML-Management System mit Value Objects.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Drift Detection Konfiguration
|
||||||
|
* - Performance Monitoring Settings
|
||||||
|
* - Auto-Tuning Configuration
|
||||||
|
* - Cache Strategien
|
||||||
|
* - Alert Thresholds
|
||||||
|
*/
|
||||||
|
final readonly class MLConfig
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public bool $monitoringEnabled = true,
|
||||||
|
public float $driftThreshold = 0.15,
|
||||||
|
public Duration $performanceWindow = new Duration(86400), // 24 hours
|
||||||
|
public bool $autoTuningEnabled = false,
|
||||||
|
public Duration $predictionCacheTtl = new Duration(3600), // 1 hour
|
||||||
|
public Duration $modelCacheTtl = new Duration(7200), // 2 hours
|
||||||
|
public Duration $baselineUpdateInterval = new Duration(86400), // 24 hours
|
||||||
|
public int $minPredictionsForDrift = 100,
|
||||||
|
public float $confidenceAlertThreshold = 0.65,
|
||||||
|
public float $accuracyAlertThreshold = 0.75
|
||||||
|
) {
|
||||||
|
// Validation
|
||||||
|
if ($driftThreshold < 0.0 || $driftThreshold > 1.0) {
|
||||||
|
throw new \InvalidArgumentException('Drift threshold must be between 0.0 and 1.0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($confidenceAlertThreshold < 0.0 || $confidenceAlertThreshold > 1.0) {
|
||||||
|
throw new \InvalidArgumentException('Confidence alert threshold must be between 0.0 and 1.0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($accuracyAlertThreshold < 0.0 || $accuracyAlertThreshold > 1.0) {
|
||||||
|
throw new \InvalidArgumentException('Accuracy alert threshold must be between 0.0 and 1.0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($minPredictionsForDrift < 1) {
|
||||||
|
throw new \InvalidArgumentException('Minimum predictions for drift must be at least 1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create configuration from environment
|
||||||
|
*/
|
||||||
|
public static function fromEnvironment(array $env = []): self
|
||||||
|
{
|
||||||
|
$getEnv = fn(string $key, mixed $default = null): mixed => $env[$key] ?? $_ENV[$key] ?? $default;
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
monitoringEnabled: filter_var($getEnv('ML_MONITORING_ENABLED', true), FILTER_VALIDATE_BOOLEAN),
|
||||||
|
driftThreshold: (float) $getEnv('ML_DRIFT_THRESHOLD', 0.15),
|
||||||
|
performanceWindow: Duration::fromHours((int) $getEnv('ML_PERFORMANCE_WINDOW_HOURS', 24)),
|
||||||
|
autoTuningEnabled: filter_var($getEnv('ML_AUTO_TUNING_ENABLED', false), FILTER_VALIDATE_BOOLEAN),
|
||||||
|
predictionCacheTtl: Duration::fromSeconds((int) $getEnv('ML_PREDICTION_CACHE_TTL', 3600)),
|
||||||
|
modelCacheTtl: Duration::fromSeconds((int) $getEnv('ML_MODEL_CACHE_TTL', 7200)),
|
||||||
|
baselineUpdateInterval: Duration::fromSeconds((int) $getEnv('ML_BASELINE_UPDATE_INTERVAL', 86400)),
|
||||||
|
minPredictionsForDrift: (int) $getEnv('ML_MIN_PREDICTIONS_FOR_DRIFT', 100),
|
||||||
|
confidenceAlertThreshold: (float) $getEnv('ML_CONFIDENCE_ALERT_THRESHOLD', 0.65),
|
||||||
|
accuracyAlertThreshold: (float) $getEnv('ML_ACCURACY_ALERT_THRESHOLD', 0.75)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production-optimized configuration
|
||||||
|
*/
|
||||||
|
public static function production(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
monitoringEnabled: true,
|
||||||
|
driftThreshold: 0.15,
|
||||||
|
performanceWindow: Duration::fromHours(24),
|
||||||
|
autoTuningEnabled: true,
|
||||||
|
predictionCacheTtl: Duration::fromHours(1),
|
||||||
|
modelCacheTtl: Duration::fromHours(2),
|
||||||
|
baselineUpdateInterval: Duration::fromHours(24),
|
||||||
|
minPredictionsForDrift: 100,
|
||||||
|
confidenceAlertThreshold: 0.65,
|
||||||
|
accuracyAlertThreshold: 0.75
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development configuration
|
||||||
|
*/
|
||||||
|
public static function development(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
monitoringEnabled: true,
|
||||||
|
driftThreshold: 0.20,
|
||||||
|
performanceWindow: Duration::fromHours(1),
|
||||||
|
autoTuningEnabled: false,
|
||||||
|
predictionCacheTtl: Duration::fromMinutes(5),
|
||||||
|
modelCacheTtl: Duration::fromMinutes(10),
|
||||||
|
baselineUpdateInterval: Duration::fromHours(1),
|
||||||
|
minPredictionsForDrift: 10,
|
||||||
|
confidenceAlertThreshold: 0.60,
|
||||||
|
accuracyAlertThreshold: 0.70
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testing configuration
|
||||||
|
*/
|
||||||
|
public static function testing(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
monitoringEnabled: false,
|
||||||
|
driftThreshold: 0.25,
|
||||||
|
performanceWindow: Duration::fromMinutes(5),
|
||||||
|
autoTuningEnabled: false,
|
||||||
|
predictionCacheTtl: Duration::fromSeconds(10),
|
||||||
|
modelCacheTtl: Duration::fromSeconds(10),
|
||||||
|
baselineUpdateInterval: Duration::fromMinutes(5),
|
||||||
|
minPredictionsForDrift: 5,
|
||||||
|
confidenceAlertThreshold: 0.50,
|
||||||
|
accuracyAlertThreshold: 0.60
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if drift detection is enabled
|
||||||
|
*/
|
||||||
|
public function isDriftDetectionEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->monitoringEnabled && $this->minPredictionsForDrift > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a drift value exceeds threshold
|
||||||
|
*/
|
||||||
|
public function isDriftDetected(float $driftValue): bool
|
||||||
|
{
|
||||||
|
return $driftValue > $this->driftThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if confidence is below alert threshold
|
||||||
|
*/
|
||||||
|
public function isLowConfidence(float $confidence): bool
|
||||||
|
{
|
||||||
|
return $confidence < $this->confidenceAlertThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if accuracy is below alert threshold
|
||||||
|
*/
|
||||||
|
public function isLowAccuracy(float $accuracy): bool
|
||||||
|
{
|
||||||
|
return $accuracy < $this->accuracyAlertThreshold;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Framework\MachineLearning\ModelManagement;
|
namespace App\Framework\MachineLearning\ModelManagement;
|
||||||
|
|
||||||
use App\Framework\Attributes\Initializer;
|
|
||||||
use App\Framework\DI\Container;
|
use App\Framework\DI\Container;
|
||||||
use App\Framework\Cache\Cache;
|
use App\Framework\Database\ConnectionInterface;
|
||||||
|
use App\Framework\DI\Initializer;
|
||||||
use App\Framework\Random\SecureRandomGenerator;
|
use App\Framework\Random\SecureRandomGenerator;
|
||||||
|
use App\Framework\Notification\NotificationDispatcher;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ML Model Management Initializer
|
* ML Model Management Initializer
|
||||||
@@ -15,11 +16,11 @@ use App\Framework\Random\SecureRandomGenerator;
|
|||||||
* Registers all ML Model Management services in the DI container.
|
* Registers all ML Model Management services in the DI container.
|
||||||
*
|
*
|
||||||
* Registered Services:
|
* Registered Services:
|
||||||
* - ModelRegistry (CacheModelRegistry)
|
* - ModelRegistry (DatabaseModelRegistry)
|
||||||
* - ABTestingService
|
* - ABTestingService
|
||||||
* - ModelPerformanceMonitor
|
* - ModelPerformanceMonitor
|
||||||
* - AutoTuningEngine
|
* - AutoTuningEngine
|
||||||
* - PerformanceStorage (CachePerformanceStorage)
|
* - PerformanceStorage (DatabasePerformanceStorage)
|
||||||
* - AlertingService (LogAlertingService)
|
* - AlertingService (LogAlertingService)
|
||||||
*/
|
*/
|
||||||
final readonly class MLModelManagementInitializer
|
final readonly class MLModelManagementInitializer
|
||||||
@@ -31,28 +32,36 @@ final readonly class MLModelManagementInitializer
|
|||||||
#[Initializer]
|
#[Initializer]
|
||||||
public function initialize(): void
|
public function initialize(): void
|
||||||
{
|
{
|
||||||
// Register ModelRegistry as singleton
|
// Register MLConfig as singleton
|
||||||
|
$this->container->singleton(
|
||||||
|
MLConfig::class,
|
||||||
|
fn(Container $c) => MLConfig::fromEnvironment()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register ModelRegistry as singleton (Database-backed)
|
||||||
$this->container->singleton(
|
$this->container->singleton(
|
||||||
ModelRegistry::class,
|
ModelRegistry::class,
|
||||||
fn(Container $c) => new CacheModelRegistry(
|
fn(Container $c) => new DatabaseModelRegistry(
|
||||||
cache: $c->get(Cache::class),
|
connection: $c->get(ConnectionInterface::class)
|
||||||
ttlDays: 7
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register PerformanceStorage as singleton
|
// Register PerformanceStorage as singleton (Database-backed)
|
||||||
$this->container->singleton(
|
$this->container->singleton(
|
||||||
PerformanceStorage::class,
|
PerformanceStorage::class,
|
||||||
fn(Container $c) => new CachePerformanceStorage(
|
fn(Container $c) => new DatabasePerformanceStorage(
|
||||||
cache: $c->get(Cache::class),
|
connection: $c->get(ConnectionInterface::class)
|
||||||
ttlDays: 30 // Keep performance data for 30 days
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register AlertingService as singleton
|
// Register AlertingService as singleton (Notification-based)
|
||||||
$this->container->singleton(
|
$this->container->singleton(
|
||||||
AlertingService::class,
|
AlertingService::class,
|
||||||
fn(Container $c) => new LogAlertingService()
|
fn(Container $c) => new NotificationAlertingService(
|
||||||
|
dispatcher: $c->get(NotificationDispatcher::class),
|
||||||
|
config: $c->get(MLConfig::class),
|
||||||
|
adminRecipientId: 'admin'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register ABTestingService
|
// Register ABTestingService
|
||||||
@@ -70,7 +79,8 @@ final readonly class MLModelManagementInitializer
|
|||||||
fn(Container $c) => new ModelPerformanceMonitor(
|
fn(Container $c) => new ModelPerformanceMonitor(
|
||||||
registry: $c->get(ModelRegistry::class),
|
registry: $c->get(ModelRegistry::class),
|
||||||
storage: $c->get(PerformanceStorage::class),
|
storage: $c->get(PerformanceStorage::class),
|
||||||
alerting: $c->get(AlertingService::class)
|
alerting: $c->get(AlertingService::class),
|
||||||
|
config: $c->get(MLConfig::class)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -44,11 +44,13 @@ final readonly class ModelPerformanceMonitor
|
|||||||
* @param ModelRegistry $registry Model registry for baseline comparison
|
* @param ModelRegistry $registry Model registry for baseline comparison
|
||||||
* @param PerformanceStorage $storage Performance data storage
|
* @param PerformanceStorage $storage Performance data storage
|
||||||
* @param AlertingService $alerting Alert service for notifications
|
* @param AlertingService $alerting Alert service for notifications
|
||||||
|
* @param MLConfig $config ML configuration settings
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ModelRegistry $registry,
|
private ModelRegistry $registry,
|
||||||
private PerformanceStorage $storage,
|
private PerformanceStorage $storage,
|
||||||
private AlertingService $alerting
|
private AlertingService $alerting,
|
||||||
|
private MLConfig $config
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\MachineLearning\ModelManagement;
|
||||||
|
|
||||||
|
use App\Framework\MachineLearning\ModelManagement\ValueObjects\MLNotificationType;
|
||||||
|
use App\Framework\Notification\Notification;
|
||||||
|
use App\Framework\Notification\NotificationDispatcherInterface;
|
||||||
|
use App\Framework\Notification\ValueObjects\NotificationChannel;
|
||||||
|
use App\Framework\Notification\ValueObjects\NotificationPriority;
|
||||||
|
use App\Framework\Core\ValueObjects\Version;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification-based Alerting Service for ML Model Management
|
||||||
|
*
|
||||||
|
* Uses the framework's Notification system for ML alerts:
|
||||||
|
* - Email notifications for critical alerts
|
||||||
|
* - Database persistence for audit trail
|
||||||
|
* - Async delivery via Queue system
|
||||||
|
* - Priority-based routing
|
||||||
|
*/
|
||||||
|
final readonly class NotificationAlertingService implements AlertingService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private NotificationDispatcherInterface $dispatcher,
|
||||||
|
private MLConfig $config,
|
||||||
|
private string $adminRecipientId = 'admin'
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function sendAlert(
|
||||||
|
string $level,
|
||||||
|
string $title,
|
||||||
|
string $message,
|
||||||
|
array $data = []
|
||||||
|
): void {
|
||||||
|
// Map level to notification type and priority
|
||||||
|
[$type, $priority] = $this->mapLevelToTypeAndPriority($level);
|
||||||
|
|
||||||
|
$notification = Notification::create(
|
||||||
|
$this->adminRecipientId,
|
||||||
|
$type,
|
||||||
|
$title,
|
||||||
|
$message,
|
||||||
|
...$type->getRecommendedChannels()
|
||||||
|
)
|
||||||
|
->withPriority($priority)
|
||||||
|
->withData($data);
|
||||||
|
|
||||||
|
// Send asynchronously
|
||||||
|
$this->dispatcher->send($notification, async: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapLevelToTypeAndPriority(string $level): array
|
||||||
|
{
|
||||||
|
return match (strtolower($level)) {
|
||||||
|
'critical' => [MLNotificationType::PERFORMANCE_DEGRADATION, NotificationPriority::URGENT],
|
||||||
|
'warning' => [MLNotificationType::LOW_CONFIDENCE, NotificationPriority::HIGH],
|
||||||
|
'info' => [MLNotificationType::MODEL_DEPLOYED, NotificationPriority::NORMAL],
|
||||||
|
default => [MLNotificationType::MODEL_DEPLOYED, NotificationPriority::LOW],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function alertDriftDetected(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
float $driftValue
|
||||||
|
): void {
|
||||||
|
if (!$this->config->monitoringEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = Notification::create(
|
||||||
|
$this->adminRecipientId,
|
||||||
|
MLNotificationType::DRIFT_DETECTED,
|
||||||
|
"Model Drift Detected: {$modelName}",
|
||||||
|
$this->buildDriftMessage($modelName, $version, $driftValue),
|
||||||
|
...MLNotificationType::DRIFT_DETECTED->getRecommendedChannels()
|
||||||
|
)
|
||||||
|
->withPriority(NotificationPriority::HIGH)
|
||||||
|
->withData([
|
||||||
|
'model_name' => $modelName,
|
||||||
|
'version' => $version->toString(),
|
||||||
|
'drift_value' => $driftValue,
|
||||||
|
'threshold' => $this->config->driftThreshold,
|
||||||
|
'detection_time' => time(),
|
||||||
|
])
|
||||||
|
->withAction(
|
||||||
|
url: "/admin/ml/models/{$modelName}",
|
||||||
|
label: 'View Model Details'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send asynchronously
|
||||||
|
$this->dispatcher->send($notification, async: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function alertPerformanceDegradation(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
float $currentAccuracy,
|
||||||
|
float $baselineAccuracy
|
||||||
|
): void {
|
||||||
|
if (!$this->config->monitoringEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$degradationPercent = (($baselineAccuracy - $currentAccuracy) / $baselineAccuracy) * 100;
|
||||||
|
|
||||||
|
$notification = Notification::create(
|
||||||
|
$this->adminRecipientId,
|
||||||
|
MLNotificationType::PERFORMANCE_DEGRADATION,
|
||||||
|
"Performance Degradation: {$modelName}",
|
||||||
|
$this->buildPerformanceDegradationMessage(
|
||||||
|
$modelName,
|
||||||
|
$version,
|
||||||
|
$currentAccuracy,
|
||||||
|
$baselineAccuracy,
|
||||||
|
$degradationPercent
|
||||||
|
),
|
||||||
|
...MLNotificationType::PERFORMANCE_DEGRADATION->getRecommendedChannels()
|
||||||
|
)
|
||||||
|
->withPriority(NotificationPriority::URGENT)
|
||||||
|
->withData([
|
||||||
|
'model_name' => $modelName,
|
||||||
|
'version' => $version->toString(),
|
||||||
|
'current_accuracy' => $currentAccuracy,
|
||||||
|
'baseline_accuracy' => $baselineAccuracy,
|
||||||
|
'degradation_percent' => $degradationPercent,
|
||||||
|
'detection_time' => time(),
|
||||||
|
])
|
||||||
|
->withAction(
|
||||||
|
url: "/admin/ml/models/{$modelName}",
|
||||||
|
label: 'Investigate Issue'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send asynchronously
|
||||||
|
$this->dispatcher->send($notification, async: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function alertLowConfidence(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
float $averageConfidence
|
||||||
|
): void {
|
||||||
|
if (!$this->config->monitoringEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = Notification::create(
|
||||||
|
$this->adminRecipientId,
|
||||||
|
MLNotificationType::LOW_CONFIDENCE,
|
||||||
|
"Low Confidence Warning: {$modelName}",
|
||||||
|
$this->buildLowConfidenceMessage($modelName, $version, $averageConfidence),
|
||||||
|
...MLNotificationType::LOW_CONFIDENCE->getRecommendedChannels()
|
||||||
|
)
|
||||||
|
->withPriority(NotificationPriority::NORMAL)
|
||||||
|
->withData([
|
||||||
|
'model_name' => $modelName,
|
||||||
|
'version' => $version->toString(),
|
||||||
|
'average_confidence' => $averageConfidence,
|
||||||
|
'threshold' => $this->config->confidenceAlertThreshold,
|
||||||
|
'detection_time' => time(),
|
||||||
|
])
|
||||||
|
->withAction(
|
||||||
|
url: "/admin/ml/models/{$modelName}",
|
||||||
|
label: 'Review Predictions'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send asynchronously
|
||||||
|
$this->dispatcher->send($notification, async: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function alertModelDeployed(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
string $environment
|
||||||
|
): void {
|
||||||
|
$notification = Notification::create(
|
||||||
|
$this->adminRecipientId,
|
||||||
|
MLNotificationType::MODEL_DEPLOYED,
|
||||||
|
"Model Deployed: {$modelName} v{$version->toString()}",
|
||||||
|
"Model {$modelName} version {$version->toString()} has been deployed to {$environment} environment.",
|
||||||
|
...MLNotificationType::MODEL_DEPLOYED->getRecommendedChannels()
|
||||||
|
)
|
||||||
|
->withPriority(NotificationPriority::LOW)
|
||||||
|
->withData([
|
||||||
|
'model_name' => $modelName,
|
||||||
|
'version' => $version->toString(),
|
||||||
|
'environment' => $environment,
|
||||||
|
'deployment_time' => time(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send asynchronously
|
||||||
|
$this->dispatcher->send($notification, async: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function alertAutoTuningTriggered(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
array $suggestedParameters
|
||||||
|
): void {
|
||||||
|
if (!$this->config->autoTuningEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = Notification::create(
|
||||||
|
$this->adminRecipientId,
|
||||||
|
MLNotificationType::AUTO_TUNING_TRIGGERED,
|
||||||
|
"Auto-Tuning Triggered: {$modelName}",
|
||||||
|
"Auto-tuning has been triggered for model {$modelName} v{$version->toString()} based on performance analysis.",
|
||||||
|
NotificationChannel::DATABASE
|
||||||
|
)
|
||||||
|
->withPriority(NotificationPriority::NORMAL)
|
||||||
|
->withData([
|
||||||
|
'model_name' => $modelName,
|
||||||
|
'version' => $version->toString(),
|
||||||
|
'suggested_parameters' => $suggestedParameters,
|
||||||
|
'trigger_time' => time(),
|
||||||
|
])
|
||||||
|
->withAction(
|
||||||
|
url: "/admin/ml/tuning/{$modelName}",
|
||||||
|
label: 'Review Suggestions'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send asynchronously
|
||||||
|
$this->dispatcher->send($notification, async: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDriftMessage(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
float $driftValue
|
||||||
|
): string {
|
||||||
|
$driftPercent = round($driftValue * 100, 2);
|
||||||
|
$thresholdPercent = round($this->config->driftThreshold * 100, 2);
|
||||||
|
|
||||||
|
return "Model drift detected for {$modelName} v{$version->toString()}.\n\n"
|
||||||
|
. "Drift Value: {$driftPercent}% (threshold: {$thresholdPercent}%)\n"
|
||||||
|
. "This indicates the model's predictions are deviating from the baseline.\n\n"
|
||||||
|
. "Recommended Actions:\n"
|
||||||
|
. "- Review recent predictions\n"
|
||||||
|
. "- Check for data distribution changes\n"
|
||||||
|
. "- Consider model retraining";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPerformanceDegradationMessage(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
float $currentAccuracy,
|
||||||
|
float $baselineAccuracy,
|
||||||
|
float $degradationPercent
|
||||||
|
): string {
|
||||||
|
$current = round($currentAccuracy * 100, 2);
|
||||||
|
$baseline = round($baselineAccuracy * 100, 2);
|
||||||
|
$degradation = round($degradationPercent, 2);
|
||||||
|
|
||||||
|
return "Performance degradation detected for {$modelName} v{$version->toString()}.\n\n"
|
||||||
|
. "Current Accuracy: {$current}%\n"
|
||||||
|
. "Baseline Accuracy: {$baseline}%\n"
|
||||||
|
. "Degradation: {$degradation}%\n\n"
|
||||||
|
. "Immediate action required:\n"
|
||||||
|
. "- Investigate root cause\n"
|
||||||
|
. "- Review recent data quality\n"
|
||||||
|
. "- Consider model rollback or retraining";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildLowConfidenceMessage(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
float $averageConfidence
|
||||||
|
): string {
|
||||||
|
$confidence = round($averageConfidence * 100, 2);
|
||||||
|
$threshold = round($this->config->confidenceAlertThreshold * 100, 2);
|
||||||
|
|
||||||
|
return "Low confidence detected for {$modelName} v{$version->toString()}.\n\n"
|
||||||
|
. "Average Confidence: {$confidence}% (threshold: {$threshold}%)\n"
|
||||||
|
. "The model is showing lower confidence in its predictions than expected.\n\n"
|
||||||
|
. "Suggested Actions:\n"
|
||||||
|
. "- Review prediction patterns\n"
|
||||||
|
. "- Check input data quality\n"
|
||||||
|
. "- Monitor for further degradation";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\MachineLearning\ModelManagement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null Alerting Service - No-Op Implementation for Testing
|
||||||
|
*
|
||||||
|
* Does not send actual alerts, used for testing environments.
|
||||||
|
*/
|
||||||
|
final readonly class NullAlertingService implements AlertingService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Send performance alert (no-op)
|
||||||
|
*/
|
||||||
|
public function sendAlert(
|
||||||
|
string $level,
|
||||||
|
string $title,
|
||||||
|
string $message,
|
||||||
|
array $data = []
|
||||||
|
): void {
|
||||||
|
// No-op: do nothing in tests
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,4 +72,43 @@ interface PerformanceStorage
|
|||||||
* Clear old prediction records (cleanup)
|
* Clear old prediction records (cleanup)
|
||||||
*/
|
*/
|
||||||
public function clearOldPredictions(Duration $olderThan): int;
|
public function clearOldPredictions(Duration $olderThan): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent predictions (limit-based)
|
||||||
|
*
|
||||||
|
* @return array<array{
|
||||||
|
* model_name: string,
|
||||||
|
* version: string,
|
||||||
|
* prediction: mixed,
|
||||||
|
* actual: mixed,
|
||||||
|
* confidence: float,
|
||||||
|
* features: array,
|
||||||
|
* timestamp: \DateTimeImmutable,
|
||||||
|
* is_correct: ?bool
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function getRecentPredictions(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
int $limit
|
||||||
|
): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate accuracy from recent predictions
|
||||||
|
*/
|
||||||
|
public function calculateAccuracy(
|
||||||
|
string $modelName,
|
||||||
|
Version $version,
|
||||||
|
int $limit
|
||||||
|
): float;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get confidence baseline
|
||||||
|
*
|
||||||
|
* @return array{avg_confidence: float, std_dev_confidence: float}|null
|
||||||
|
*/
|
||||||
|
public function getConfidenceBaseline(
|
||||||
|
string $modelName,
|
||||||
|
Version $version
|
||||||
|
): ?array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\MachineLearning\ModelManagement\ValueObjects;
|
||||||
|
|
||||||
|
use App\Framework\Notification\ValueObjects\NotificationTypeInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ML-specific Notification Types
|
||||||
|
*
|
||||||
|
* Machine Learning model monitoring and alerting notification categories
|
||||||
|
*/
|
||||||
|
enum MLNotificationType: string implements NotificationTypeInterface
|
||||||
|
{
|
||||||
|
case DRIFT_DETECTED = 'ml_drift_detected';
|
||||||
|
case PERFORMANCE_DEGRADATION = 'ml_performance_degradation';
|
||||||
|
case LOW_CONFIDENCE = 'ml_low_confidence';
|
||||||
|
case LOW_ACCURACY = 'ml_low_accuracy';
|
||||||
|
case MODEL_DEPLOYED = 'ml_model_deployed';
|
||||||
|
case MODEL_RETIRED = 'ml_model_retired';
|
||||||
|
case AUTO_TUNING_TRIGGERED = 'ml_auto_tuning_triggered';
|
||||||
|
case BASELINE_UPDATED = 'ml_baseline_updated';
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDisplayName(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::DRIFT_DETECTED => 'ML Model Drift Detected',
|
||||||
|
self::PERFORMANCE_DEGRADATION => 'ML Performance Degradation',
|
||||||
|
self::LOW_CONFIDENCE => 'ML Low Confidence Warning',
|
||||||
|
self::LOW_ACCURACY => 'ML Low Accuracy Warning',
|
||||||
|
self::MODEL_DEPLOYED => 'ML Model Deployed',
|
||||||
|
self::MODEL_RETIRED => 'ML Model Retired',
|
||||||
|
self::AUTO_TUNING_TRIGGERED => 'ML Auto-Tuning Triggered',
|
||||||
|
self::BASELINE_UPDATED => 'ML Baseline Updated',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCritical(): bool
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::DRIFT_DETECTED,
|
||||||
|
self::PERFORMANCE_DEGRADATION,
|
||||||
|
self::LOW_ACCURACY => true,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this notification requires immediate action
|
||||||
|
*/
|
||||||
|
public function requiresImmediateAction(): bool
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::DRIFT_DETECTED,
|
||||||
|
self::PERFORMANCE_DEGRADATION => true,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recommended notification channels for this type
|
||||||
|
*/
|
||||||
|
public function getRecommendedChannels(): array
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::DRIFT_DETECTED,
|
||||||
|
self::PERFORMANCE_DEGRADATION => [
|
||||||
|
\App\Framework\Notification\ValueObjects\NotificationChannel::EMAIL,
|
||||||
|
\App\Framework\Notification\ValueObjects\NotificationChannel::DATABASE,
|
||||||
|
],
|
||||||
|
self::LOW_CONFIDENCE,
|
||||||
|
self::LOW_ACCURACY => [
|
||||||
|
\App\Framework\Notification\ValueObjects\NotificationChannel::DATABASE,
|
||||||
|
\App\Framework\Notification\ValueObjects\NotificationChannel::EMAIL,
|
||||||
|
],
|
||||||
|
self::MODEL_DEPLOYED,
|
||||||
|
self::MODEL_RETIRED,
|
||||||
|
self::AUTO_TUNING_TRIGGERED,
|
||||||
|
self::BASELINE_UPDATED => [
|
||||||
|
\App\Framework\Notification\ValueObjects\NotificationChannel::DATABASE,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,7 +117,7 @@ final readonly class ModelMetadata
|
|||||||
modelType: ModelType::UNSUPERVISED,
|
modelType: ModelType::UNSUPERVISED,
|
||||||
version: $version,
|
version: $version,
|
||||||
configuration: array_merge([
|
configuration: array_merge([
|
||||||
'anomaly_threshold' => 50, // Score 0-100
|
'anomaly_threshold' => 0.5, // Score 0.0-1.0 (0.5 = 50% threshold)
|
||||||
'z_score_threshold' => 3.0,
|
'z_score_threshold' => 3.0,
|
||||||
'iqr_multiplier' => 1.5,
|
'iqr_multiplier' => 1.5,
|
||||||
'feature_weights' => [
|
'feature_weights' => [
|
||||||
@@ -286,7 +286,8 @@ final readonly class ModelMetadata
|
|||||||
*/
|
*/
|
||||||
public function getAgeInDays(): int
|
public function getAgeInDays(): int
|
||||||
{
|
{
|
||||||
return (int) $this->createdAt->diffInDays(Timestamp::now());
|
$duration = Timestamp::now()->diff($this->createdAt);
|
||||||
|
return (int) floor($duration->toHours() / 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -298,7 +299,8 @@ final readonly class ModelMetadata
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (int) $this->deployedAt->diffInDays(Timestamp::now());
|
$duration = Timestamp::now()->diff($this->deployedAt);
|
||||||
|
return (int) floor($duration->toHours() / 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -320,8 +322,8 @@ final readonly class ModelMetadata
|
|||||||
],
|
],
|
||||||
'configuration' => $this->configuration,
|
'configuration' => $this->configuration,
|
||||||
'performance_metrics' => $this->performanceMetrics,
|
'performance_metrics' => $this->performanceMetrics,
|
||||||
'created_at' => $this->createdAt->toString(),
|
'created_at' => (string) $this->createdAt,
|
||||||
'deployed_at' => $this->deployedAt?->toString(),
|
'deployed_at' => $this->deployedAt ? (string) $this->deployedAt : null,
|
||||||
'environment' => $this->environment,
|
'environment' => $this->environment,
|
||||||
'is_deployed' => $this->isDeployed(),
|
'is_deployed' => $this->isDeployed(),
|
||||||
'is_production' => $this->isProduction(),
|
'is_production' => $this->isProduction(),
|
||||||
@@ -343,10 +345,10 @@ final readonly class ModelMetadata
|
|||||||
configuration: $data['configuration'] ?? [],
|
configuration: $data['configuration'] ?? [],
|
||||||
performanceMetrics: $data['performance_metrics'] ?? [],
|
performanceMetrics: $data['performance_metrics'] ?? [],
|
||||||
createdAt: isset($data['created_at'])
|
createdAt: isset($data['created_at'])
|
||||||
? Timestamp::fromString($data['created_at'])
|
? Timestamp::fromDateTime(new \DateTimeImmutable($data['created_at']))
|
||||||
: Timestamp::now(),
|
: Timestamp::now(),
|
||||||
deployedAt: isset($data['deployed_at'])
|
deployedAt: isset($data['deployed_at']) && $data['deployed_at'] !== null
|
||||||
? Timestamp::fromString($data['deployed_at'])
|
? Timestamp::fromDateTime(new \DateTimeImmutable($data['deployed_at']))
|
||||||
: null,
|
: null,
|
||||||
environment: $data['environment'] ?? null,
|
environment: $data['environment'] ?? null,
|
||||||
metadata: $data['metadata'] ?? []
|
metadata: $data['metadata'] ?? []
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ use App\Framework\Scheduler\Schedules\IntervalSchedule;
|
|||||||
use App\Framework\Core\ValueObjects\Duration;
|
use App\Framework\Core\ValueObjects\Duration;
|
||||||
use App\Framework\Core\ValueObjects\Version;
|
use App\Framework\Core\ValueObjects\Version;
|
||||||
use App\Framework\Waf\MachineLearning\WafBehavioralModelAdapter;
|
use App\Framework\Waf\MachineLearning\WafBehavioralModelAdapter;
|
||||||
use Psr\Log\LoggerInterface;
|
use App\Framework\Logging\Logger;
|
||||||
|
use App\Framework\Logging\ValueObjects\LogContext;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ML Monitoring Scheduler
|
* ML Monitoring Scheduler
|
||||||
@@ -39,7 +40,7 @@ final readonly class MLMonitoringScheduler
|
|||||||
private ModelPerformanceMonitor $performanceMonitor,
|
private ModelPerformanceMonitor $performanceMonitor,
|
||||||
private AutoTuningEngine $autoTuning,
|
private AutoTuningEngine $autoTuning,
|
||||||
private AlertingService $alerting,
|
private AlertingService $alerting,
|
||||||
private LoggerInterface $logger,
|
private Logger $logger,
|
||||||
private ?NPlusOneModelAdapter $n1Adapter = null,
|
private ?NPlusOneModelAdapter $n1Adapter = null,
|
||||||
private ?WafBehavioralModelAdapter $wafAdapter = null,
|
private ?WafBehavioralModelAdapter $wafAdapter = null,
|
||||||
private ?QueueAnomalyModelAdapter $queueAdapter = null
|
private ?QueueAnomalyModelAdapter $queueAdapter = null
|
||||||
@@ -55,10 +56,10 @@ final readonly class MLMonitoringScheduler
|
|||||||
$this->scheduleAutoTuning();
|
$this->scheduleAutoTuning();
|
||||||
$this->scheduleRegistryCleanup();
|
$this->scheduleRegistryCleanup();
|
||||||
|
|
||||||
$this->logger->info('ML monitoring scheduler initialized', [
|
$this->logger->info('ML monitoring scheduler initialized', LogContext::withData([
|
||||||
'jobs_scheduled' => 4,
|
'jobs_scheduled' => 4,
|
||||||
'models_monitored' => $this->getActiveModels(),
|
'models_monitored' => $this->getActiveModels(),
|
||||||
]);
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,9 +95,9 @@ final readonly class MLMonitoringScheduler
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('N+1 monitoring failed', [
|
$this->logger->error('N+1 monitoring failed', LogContext::withData([
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]));
|
||||||
$results['n1-detector'] = ['status' => 'error'];
|
$results['n1-detector'] = ['status' => 'error'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,9 +122,9 @@ final readonly class MLMonitoringScheduler
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('WAF monitoring failed', [
|
$this->logger->error('WAF monitoring failed', LogContext::withData([
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]));
|
||||||
$results['waf-behavioral'] = ['status' => 'error'];
|
$results['waf-behavioral'] = ['status' => 'error'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,9 +149,9 @@ final readonly class MLMonitoringScheduler
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('Queue monitoring failed', [
|
$this->logger->error('Queue monitoring failed', LogContext::withData([
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]));
|
||||||
$results['queue-anomaly'] = ['status' => 'error'];
|
$results['queue-anomaly'] = ['status' => 'error'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,9 +193,9 @@ final readonly class MLMonitoringScheduler
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('N+1 degradation check failed', [
|
$this->logger->error('N+1 degradation check failed', LogContext::withData([
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]));
|
||||||
$results['n1-detector'] = ['status' => 'error'];
|
$results['n1-detector'] = ['status' => 'error'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,9 +219,9 @@ final readonly class MLMonitoringScheduler
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('WAF degradation check failed', [
|
$this->logger->error('WAF degradation check failed', LogContext::withData([
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]));
|
||||||
$results['waf-behavioral'] = ['status' => 'error'];
|
$results['waf-behavioral'] = ['status' => 'error'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,9 +245,9 @@ final readonly class MLMonitoringScheduler
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('Queue degradation check failed', [
|
$this->logger->error('Queue degradation check failed', LogContext::withData([
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]));
|
||||||
$results['queue-anomaly'] = ['status' => 'error'];
|
$results['queue-anomaly'] = ['status' => 'error'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,15 +296,15 @@ final readonly class MLMonitoringScheduler
|
|||||||
|
|
||||||
$this->n1Adapter->updateConfiguration($newConfig);
|
$this->n1Adapter->updateConfiguration($newConfig);
|
||||||
|
|
||||||
$this->logger->info('N+1 detector auto-tuned', [
|
$this->logger->info('N+1 detector auto-tuned', LogContext::withData([
|
||||||
'new_threshold' => $optimizationResult['optimal_threshold'],
|
'new_threshold' => $optimizationResult['optimal_threshold'],
|
||||||
'improvement' => $optimizationResult['improvement_percent'],
|
'improvement' => $optimizationResult['improvement_percent'],
|
||||||
]);
|
]));
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('N+1 auto-tuning failed', [
|
$this->logger->error('N+1 auto-tuning failed', LogContext::withData([
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]));
|
||||||
$results['n1-detector'] = ['status' => 'error'];
|
$results['n1-detector'] = ['status' => 'error'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,15 +335,15 @@ final readonly class MLMonitoringScheduler
|
|||||||
|
|
||||||
$this->wafAdapter->updateConfiguration($newConfig);
|
$this->wafAdapter->updateConfiguration($newConfig);
|
||||||
|
|
||||||
$this->logger->info('WAF behavioral auto-tuned', [
|
$this->logger->info('WAF behavioral auto-tuned', LogContext::withData([
|
||||||
'new_threshold' => $optimizationResult['optimal_threshold'],
|
'new_threshold' => $optimizationResult['optimal_threshold'],
|
||||||
'improvement' => $optimizationResult['improvement_percent'],
|
'improvement' => $optimizationResult['improvement_percent'],
|
||||||
]);
|
]));
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('WAF auto-tuning failed', [
|
$this->logger->error('WAF auto-tuning failed', LogContext::withData([
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]));
|
||||||
$results['waf-behavioral'] = ['status' => 'error'];
|
$results['waf-behavioral'] = ['status' => 'error'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,15 +374,15 @@ final readonly class MLMonitoringScheduler
|
|||||||
|
|
||||||
$this->queueAdapter->updateConfiguration($newConfig);
|
$this->queueAdapter->updateConfiguration($newConfig);
|
||||||
|
|
||||||
$this->logger->info('Queue anomaly auto-tuned', [
|
$this->logger->info('Queue anomaly auto-tuned', LogContext::withData([
|
||||||
'new_threshold' => $optimizationResult['optimal_threshold'],
|
'new_threshold' => $optimizationResult['optimal_threshold'],
|
||||||
'improvement' => $optimizationResult['improvement_percent'],
|
'improvement' => $optimizationResult['improvement_percent'],
|
||||||
]);
|
]));
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('Queue auto-tuning failed', [
|
$this->logger->error('Queue auto-tuning failed', LogContext::withData([
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]));
|
||||||
$results['queue-anomaly'] = ['status' => 'error'];
|
$results['queue-anomaly'] = ['status' => 'error'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,18 +407,18 @@ final readonly class MLMonitoringScheduler
|
|||||||
// Get all production models
|
// Get all production models
|
||||||
$productionModels = $this->registry->getProductionModels();
|
$productionModels = $this->registry->getProductionModels();
|
||||||
|
|
||||||
$this->logger->info('ML registry cleanup completed', [
|
$this->logger->info('ML registry cleanup completed', LogContext::withData([
|
||||||
'production_models' => count($productionModels),
|
'production_models' => count($productionModels),
|
||||||
]);
|
]));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'status' => 'completed',
|
'status' => 'completed',
|
||||||
'production_models' => count($productionModels),
|
'production_models' => count($productionModels),
|
||||||
];
|
];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('Registry cleanup failed', [
|
$this->logger->error('Registry cleanup failed', LogContext::withData([
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]));
|
||||||
|
|
||||||
return ['status' => 'error'];
|
return ['status' => 'error'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,13 +260,14 @@ final class SmtpTransport implements TransportInterface
|
|||||||
|
|
||||||
private function buildMultipartAlternativeMessage(Message $message, array $lines): string
|
private function buildMultipartAlternativeMessage(Message $message, array $lines): string
|
||||||
{
|
{
|
||||||
$boundary = 'alt_' . uniqid();
|
$generator = new \App\Framework\Ulid\UlidGenerator();
|
||||||
|
$boundary = 'alt_' . $generator->generate();
|
||||||
|
|
||||||
$lines[] = 'MIME-Version: 1.0';
|
$lines[] = 'MIME-Version: 1.0';
|
||||||
|
|
||||||
if ($message->hasAttachments()) {
|
if ($message->hasAttachments()) {
|
||||||
// Mixed with alternative inside
|
// Mixed with alternative inside
|
||||||
$mixedBoundary = 'mixed_' . uniqid();
|
$mixedBoundary = 'mixed_' . $generator->generate();
|
||||||
$lines[] = 'Content-Type: multipart/mixed; boundary="' . $mixedBoundary . '"';
|
$lines[] = 'Content-Type: multipart/mixed; boundary="' . $mixedBoundary . '"';
|
||||||
$lines[] = '';
|
$lines[] = '';
|
||||||
$lines[] = '--' . $mixedBoundary;
|
$lines[] = '--' . $mixedBoundary;
|
||||||
@@ -291,7 +292,8 @@ final class SmtpTransport implements TransportInterface
|
|||||||
|
|
||||||
private function buildMultipartMixedMessage(Message $message, array $lines): string
|
private function buildMultipartMixedMessage(Message $message, array $lines): string
|
||||||
{
|
{
|
||||||
$boundary = 'mixed_' . uniqid();
|
$generator = new \App\Framework\Ulid\UlidGenerator();
|
||||||
|
$boundary = 'mixed_' . $generator->generate();
|
||||||
|
|
||||||
$lines[] = 'MIME-Version: 1.0';
|
$lines[] = 'MIME-Version: 1.0';
|
||||||
$lines[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"';
|
$lines[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"';
|
||||||
@@ -375,7 +377,8 @@ final class SmtpTransport implements TransportInterface
|
|||||||
|
|
||||||
private function generateMessageId(): string
|
private function generateMessageId(): string
|
||||||
{
|
{
|
||||||
return uniqid() . '.' . time() . '@' . gethostname();
|
$generator = new \App\Framework\Ulid\UlidGenerator();
|
||||||
|
return $generator->generate() . '.' . time() . '@' . gethostname();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sendCommand(string $command): void
|
private function sendCommand(string $command): void
|
||||||
@@ -412,7 +415,8 @@ final class SmtpTransport implements TransportInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to generated ID
|
// Fallback to generated ID
|
||||||
return uniqid() . '@' . gethostname();
|
$generator = new \App\Framework\Ulid\UlidGenerator();
|
||||||
|
return $generator->generate() . '@' . gethostname();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function disconnect(): void
|
private function disconnect(): void
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ final class MockTransport implements TransportInterface
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$messageId = 'mock_' . uniqid();
|
$generator = new \App\Framework\Ulid\UlidGenerator();
|
||||||
|
$messageId = 'mock_' . $generator->generate();
|
||||||
$this->sentMessages[] = [
|
$this->sentMessages[] = [
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
'message_id' => $messageId,
|
'message_id' => $messageId,
|
||||||
|
|||||||
186
src/Framework/Notification/Channels/Telegram/ChatIdDiscovery.php
Normal file
186
src/Framework/Notification/Channels/Telegram/ChatIdDiscovery.php
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram;
|
||||||
|
|
||||||
|
use App\Framework\Http\Method;
|
||||||
|
use App\Framework\HttpClient\ClientRequest;
|
||||||
|
use App\Framework\HttpClient\HttpClient;
|
||||||
|
use App\Framework\Notification\Channels\Telegram\ValueObjects\TelegramChatId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Chat ID Discovery
|
||||||
|
*
|
||||||
|
* Helps discover chat IDs by fetching recent updates from Telegram Bot API
|
||||||
|
*/
|
||||||
|
final readonly class ChatIdDiscovery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private HttpClient $httpClient,
|
||||||
|
private TelegramConfig $config
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all recent chat IDs that have interacted with the bot
|
||||||
|
*
|
||||||
|
* @return array<TelegramChatId> Array of discovered chat IDs
|
||||||
|
*/
|
||||||
|
public function discoverChatIds(): array
|
||||||
|
{
|
||||||
|
$updates = $this->getUpdates();
|
||||||
|
|
||||||
|
$chatIds = [];
|
||||||
|
foreach ($updates as $update) {
|
||||||
|
if (isset($update['message']['chat']['id'])) {
|
||||||
|
$chatId = TelegramChatId::fromInt($update['message']['chat']['id']);
|
||||||
|
$chatIds[$chatId->toString()] = $chatId; // Use as key to avoid duplicates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($chatIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed information about recent chats
|
||||||
|
*
|
||||||
|
* @return array Array of chat information with chat_id, name, type, etc.
|
||||||
|
*/
|
||||||
|
public function discoverChatsWithInfo(): array
|
||||||
|
{
|
||||||
|
$updates = $this->getUpdates();
|
||||||
|
|
||||||
|
$chats = [];
|
||||||
|
foreach ($updates as $update) {
|
||||||
|
if (isset($update['message']['chat'])) {
|
||||||
|
$chat = $update['message']['chat'];
|
||||||
|
$chatId = (string) $chat['id'];
|
||||||
|
|
||||||
|
if (!isset($chats[$chatId])) {
|
||||||
|
$chats[$chatId] = [
|
||||||
|
'chat_id' => TelegramChatId::fromInt($chat['id']),
|
||||||
|
'type' => $chat['type'] ?? 'unknown',
|
||||||
|
'title' => $chat['title'] ?? null,
|
||||||
|
'username' => $chat['username'] ?? null,
|
||||||
|
'first_name' => $chat['first_name'] ?? null,
|
||||||
|
'last_name' => $chat['last_name'] ?? null,
|
||||||
|
'last_message_text' => $update['message']['text'] ?? null,
|
||||||
|
'last_message_date' => $update['message']['date'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($chats);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the most recent chat ID (usually yours if you just messaged the bot)
|
||||||
|
*
|
||||||
|
* @return TelegramChatId|null Most recent chat ID or null if no updates
|
||||||
|
*/
|
||||||
|
public function getMostRecentChatId(): ?TelegramChatId
|
||||||
|
{
|
||||||
|
$updates = $this->getUpdates();
|
||||||
|
|
||||||
|
if (empty($updates)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates are ordered by update_id (oldest first), so we get the last one
|
||||||
|
$latestUpdate = end($updates);
|
||||||
|
|
||||||
|
if (isset($latestUpdate['message']['chat']['id'])) {
|
||||||
|
return TelegramChatId::fromInt($latestUpdate['message']['chat']['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch recent updates from Telegram API
|
||||||
|
*
|
||||||
|
* @return array Array of update objects
|
||||||
|
*/
|
||||||
|
private function getUpdates(): array
|
||||||
|
{
|
||||||
|
$request = ClientRequest::json(
|
||||||
|
method: Method::POST,
|
||||||
|
url: $this->config->getGetUpdatesEndpoint(),
|
||||||
|
data: []
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->httpClient->send($request);
|
||||||
|
|
||||||
|
if (!$response->isSuccessful()) {
|
||||||
|
throw new TelegramApiException(
|
||||||
|
"Failed to get updates: HTTP {$response->status->value}",
|
||||||
|
$response->status->value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
if (!isset($data['ok']) || $data['ok'] !== true) {
|
||||||
|
$errorMessage = $data['description'] ?? 'Unknown error';
|
||||||
|
throw new TelegramApiException(
|
||||||
|
"Telegram API error: {$errorMessage}",
|
||||||
|
$data['error_code'] ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data['result'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print discovered chats in a human-readable format
|
||||||
|
*/
|
||||||
|
public function printDiscoveredChats(): void
|
||||||
|
{
|
||||||
|
$chats = $this->discoverChatsWithInfo();
|
||||||
|
|
||||||
|
if (empty($chats)) {
|
||||||
|
echo "ℹ️ No chats found. Please send a message to your bot first.\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "📋 Discovered Chats:\n";
|
||||||
|
echo str_repeat('=', 60) . "\n\n";
|
||||||
|
|
||||||
|
foreach ($chats as $index => $chat) {
|
||||||
|
echo sprintf("#%d\n", $index + 1);
|
||||||
|
echo sprintf(" 💬 Chat ID: %s\n", $chat['chat_id']->toString());
|
||||||
|
echo sprintf(" 📱 Type: %s\n", $chat['type']);
|
||||||
|
|
||||||
|
if ($chat['username']) {
|
||||||
|
echo sprintf(" 👤 Username: @%s\n", $chat['username']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($chat['first_name']) {
|
||||||
|
$fullName = $chat['first_name'];
|
||||||
|
if ($chat['last_name']) {
|
||||||
|
$fullName .= ' ' . $chat['last_name'];
|
||||||
|
}
|
||||||
|
echo sprintf(" 📛 Name: %s\n", $fullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($chat['title']) {
|
||||||
|
echo sprintf(" 🏷️ Title: %s\n", $chat['title']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($chat['last_message_text']) {
|
||||||
|
$messagePreview = strlen($chat['last_message_text']) > 50
|
||||||
|
? substr($chat['last_message_text'], 0, 50) . '...'
|
||||||
|
: $chat['last_message_text'];
|
||||||
|
echo sprintf(" 💬 Last Message: %s\n", $messagePreview);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($chat['last_message_date']) {
|
||||||
|
echo sprintf(" 📅 Last Message Date: %s\n", date('Y-m-d H:i:s', $chat['last_message_date']));
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram;
|
||||||
|
|
||||||
|
use App\Framework\Notification\Channels\Telegram\ValueObjects\TelegramChatId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed chat ID resolver
|
||||||
|
*
|
||||||
|
* Returns a single hardcoded chat ID for all users
|
||||||
|
* Useful for development/testing or single-user scenarios
|
||||||
|
*/
|
||||||
|
final readonly class FixedChatIdResolver implements UserChatIdResolver
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TelegramChatId $chatId
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always returns the same chat ID regardless of user ID
|
||||||
|
*/
|
||||||
|
public function resolveChatId(string $userId): ?TelegramChatId
|
||||||
|
{
|
||||||
|
return $this->chatId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create resolver with default chat ID
|
||||||
|
*/
|
||||||
|
public static function createDefault(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
chatId: TelegramChatId::fromString('8240973979')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram API Exception
|
||||||
|
*
|
||||||
|
* Thrown when Telegram API returns an error
|
||||||
|
*/
|
||||||
|
final class TelegramApiException extends \RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message,
|
||||||
|
int $code = 0,
|
||||||
|
?\Throwable $previous = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
476
src/Framework/Notification/Channels/Telegram/TelegramClient.php
Normal file
476
src/Framework/Notification/Channels/Telegram/TelegramClient.php
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram;
|
||||||
|
|
||||||
|
use App\Framework\Http\Method;
|
||||||
|
use App\Framework\HttpClient\ClientRequest;
|
||||||
|
use App\Framework\HttpClient\HttpClient;
|
||||||
|
use App\Framework\Notification\Channels\Telegram\ValueObjects\{TelegramChatId, TelegramMessageId, InlineKeyboard};
|
||||||
|
use App\Framework\Notification\Channels\Telegram\Webhook\{CallbackResponse, TelegramCallbackQuery};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Bot API client
|
||||||
|
*
|
||||||
|
* Handles communication with Telegram Bot API using framework's HttpClient
|
||||||
|
*/
|
||||||
|
final readonly class TelegramClient
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private HttpClient $httpClient,
|
||||||
|
private TelegramConfig $config
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a text message
|
||||||
|
*
|
||||||
|
* @param TelegramChatId $chatId Recipient chat ID
|
||||||
|
* @param string $text Message text (1-4096 characters)
|
||||||
|
* @param string|null $parseMode Message format (Markdown, MarkdownV2, HTML)
|
||||||
|
* @param InlineKeyboard|null $keyboard Inline keyboard with action buttons
|
||||||
|
*/
|
||||||
|
public function sendMessage(
|
||||||
|
TelegramChatId $chatId,
|
||||||
|
string $text,
|
||||||
|
?string $parseMode = null,
|
||||||
|
?InlineKeyboard $keyboard = null
|
||||||
|
): TelegramResponse {
|
||||||
|
$payload = [
|
||||||
|
'chat_id' => $chatId->toString(),
|
||||||
|
'text' => $text,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($parseMode !== null) {
|
||||||
|
$payload['parse_mode'] = $parseMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($keyboard !== null) {
|
||||||
|
$payload['reply_markup'] = $keyboard->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sendRequest($this->config->getSendMessageEndpoint(), $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bot information
|
||||||
|
*/
|
||||||
|
public function getMe(): array
|
||||||
|
{
|
||||||
|
$request = ClientRequest::json(
|
||||||
|
method: Method::POST,
|
||||||
|
url: $this->config->getGetMeEndpoint(),
|
||||||
|
data: []
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->httpClient->send($request);
|
||||||
|
|
||||||
|
if (!$response->isSuccessful()) {
|
||||||
|
throw new TelegramApiException(
|
||||||
|
"Failed to get bot info: HTTP {$response->status->value}",
|
||||||
|
$response->status->value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
if (!isset($data['ok']) || $data['ok'] !== true) {
|
||||||
|
$errorMessage = $data['description'] ?? 'Unknown error';
|
||||||
|
throw new TelegramApiException(
|
||||||
|
"Telegram API error: {$errorMessage}",
|
||||||
|
$data['error_code'] ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data['result'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set webhook URL for receiving updates
|
||||||
|
*
|
||||||
|
* @param string $url HTTPS URL to receive webhook updates
|
||||||
|
* @param string|null $secretToken Secret token for webhook verification
|
||||||
|
* @param array $allowedUpdates List of update types to receive
|
||||||
|
*/
|
||||||
|
public function setWebhook(
|
||||||
|
string $url,
|
||||||
|
?string $secretToken = null,
|
||||||
|
array $allowedUpdates = []
|
||||||
|
): bool {
|
||||||
|
$payload = ['url' => $url];
|
||||||
|
|
||||||
|
if ($secretToken !== null) {
|
||||||
|
$payload['secret_token'] = $secretToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($allowedUpdates)) {
|
||||||
|
$payload['allowed_updates'] = $allowedUpdates;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = ClientRequest::json(
|
||||||
|
method: Method::POST,
|
||||||
|
url: $this->config->getSetWebhookEndpoint(),
|
||||||
|
data: $payload
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->httpClient->send($request);
|
||||||
|
|
||||||
|
if (!$response->isSuccessful()) {
|
||||||
|
throw new TelegramApiException(
|
||||||
|
"Failed to set webhook: HTTP {$response->status->value}",
|
||||||
|
$response->status->value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
if (!isset($data['ok']) || $data['ok'] !== true) {
|
||||||
|
$errorMessage = $data['description'] ?? 'Unknown error';
|
||||||
|
throw new TelegramApiException(
|
||||||
|
"Telegram API error: {$errorMessage}",
|
||||||
|
$data['error_code'] ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data['result'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete webhook and switch back to getUpdates polling
|
||||||
|
*/
|
||||||
|
public function deleteWebhook(): bool
|
||||||
|
{
|
||||||
|
$request = ClientRequest::json(
|
||||||
|
method: Method::POST,
|
||||||
|
url: $this->config->getDeleteWebhookEndpoint(),
|
||||||
|
data: []
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->httpClient->send($request);
|
||||||
|
|
||||||
|
if (!$response->isSuccessful()) {
|
||||||
|
throw new TelegramApiException(
|
||||||
|
"Failed to delete webhook: HTTP {$response->status->value}",
|
||||||
|
$response->status->value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
if (!isset($data['ok']) || $data['ok'] !== true) {
|
||||||
|
$errorMessage = $data['description'] ?? 'Unknown error';
|
||||||
|
throw new TelegramApiException(
|
||||||
|
"Telegram API error: {$errorMessage}",
|
||||||
|
$data['error_code'] ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data['result'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer callback query from inline keyboard button
|
||||||
|
*
|
||||||
|
* Must be called within 30 seconds after callback query is received
|
||||||
|
*
|
||||||
|
* @param string $callbackQueryId Unique identifier for the callback query
|
||||||
|
* @param CallbackResponse $response Response to send to user
|
||||||
|
*/
|
||||||
|
public function answerCallbackQuery(
|
||||||
|
string $callbackQueryId,
|
||||||
|
CallbackResponse $response
|
||||||
|
): bool {
|
||||||
|
$payload = [
|
||||||
|
'callback_query_id' => $callbackQueryId,
|
||||||
|
'text' => $response->text,
|
||||||
|
'show_alert' => $response->showAlert,
|
||||||
|
];
|
||||||
|
|
||||||
|
$request = ClientRequest::json(
|
||||||
|
method: Method::POST,
|
||||||
|
url: $this->config->getAnswerCallbackQueryEndpoint(),
|
||||||
|
data: $payload
|
||||||
|
);
|
||||||
|
|
||||||
|
$httpResponse = $this->httpClient->send($request);
|
||||||
|
|
||||||
|
if (!$httpResponse->isSuccessful()) {
|
||||||
|
throw new TelegramApiException(
|
||||||
|
"Failed to answer callback query: HTTP {$httpResponse->status->value}",
|
||||||
|
$httpResponse->status->value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $httpResponse->json();
|
||||||
|
|
||||||
|
if (!isset($data['ok']) || $data['ok'] !== true) {
|
||||||
|
$errorMessage = $data['description'] ?? 'Unknown error';
|
||||||
|
throw new TelegramApiException(
|
||||||
|
"Telegram API error: {$errorMessage}",
|
||||||
|
$data['error_code'] ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data['result'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit message text
|
||||||
|
*
|
||||||
|
* @param TelegramChatId $chatId Chat containing the message
|
||||||
|
* @param TelegramMessageId $messageId Message to edit
|
||||||
|
* @param string $text New text
|
||||||
|
* @param InlineKeyboard|null $keyboard Optional new keyboard
|
||||||
|
*/
|
||||||
|
public function editMessageText(
|
||||||
|
TelegramChatId $chatId,
|
||||||
|
TelegramMessageId $messageId,
|
||||||
|
string $text,
|
||||||
|
?InlineKeyboard $keyboard = null
|
||||||
|
): bool {
|
||||||
|
$payload = [
|
||||||
|
'chat_id' => $chatId->toString(),
|
||||||
|
'message_id' => $messageId->value,
|
||||||
|
'text' => $text,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($keyboard !== null) {
|
||||||
|
$payload['reply_markup'] = $keyboard->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = ClientRequest::json(
|
||||||
|
method: Method::POST,
|
||||||
|
url: $this->config->getEditMessageTextEndpoint(),
|
||||||
|
data: $payload
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->httpClient->send($request);
|
||||||
|
|
||||||
|
if (!$response->isSuccessful()) {
|
||||||
|
throw new TelegramApiException(
|
||||||
|
"Failed to edit message: HTTP {$response->status->value}",
|
||||||
|
$response->status->value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
if (!isset($data['ok']) || $data['ok'] !== true) {
|
||||||
|
$errorMessage = $data['description'] ?? 'Unknown error';
|
||||||
|
throw new TelegramApiException(
|
||||||
|
"Telegram API error: {$errorMessage}",
|
||||||
|
$data['error_code'] ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send photo
|
||||||
|
*
|
||||||
|
* @param TelegramChatId $chatId Recipient chat ID
|
||||||
|
* @param string $photo File path or file_id
|
||||||
|
* @param string|null $caption Photo caption (0-1024 characters)
|
||||||
|
* @param string|null $parseMode Caption format (Markdown, MarkdownV2, HTML)
|
||||||
|
*/
|
||||||
|
public function sendPhoto(
|
||||||
|
TelegramChatId $chatId,
|
||||||
|
string $photo,
|
||||||
|
?string $caption = null,
|
||||||
|
?string $parseMode = null
|
||||||
|
): TelegramResponse {
|
||||||
|
$payload = [
|
||||||
|
'chat_id' => $chatId->toString(),
|
||||||
|
'photo' => $photo,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($caption !== null) {
|
||||||
|
$payload['caption'] = $caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parseMode !== null) {
|
||||||
|
$payload['parse_mode'] = $parseMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sendRequest($this->config->getSendPhotoEndpoint(), $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send video
|
||||||
|
*
|
||||||
|
* @param TelegramChatId $chatId Recipient chat ID
|
||||||
|
* @param string $video File path or file_id
|
||||||
|
* @param string|null $caption Video caption (0-1024 characters)
|
||||||
|
* @param string|null $parseMode Caption format (Markdown, MarkdownV2, HTML)
|
||||||
|
* @param int|null $duration Video duration in seconds
|
||||||
|
*/
|
||||||
|
public function sendVideo(
|
||||||
|
TelegramChatId $chatId,
|
||||||
|
string $video,
|
||||||
|
?string $caption = null,
|
||||||
|
?string $parseMode = null,
|
||||||
|
?int $duration = null
|
||||||
|
): TelegramResponse {
|
||||||
|
$payload = [
|
||||||
|
'chat_id' => $chatId->toString(),
|
||||||
|
'video' => $video,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($caption !== null) {
|
||||||
|
$payload['caption'] = $caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parseMode !== null) {
|
||||||
|
$payload['parse_mode'] = $parseMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($duration !== null) {
|
||||||
|
$payload['duration'] = $duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sendRequest($this->config->getSendVideoEndpoint(), $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send audio
|
||||||
|
*
|
||||||
|
* @param TelegramChatId $chatId Recipient chat ID
|
||||||
|
* @param string $audio File path or file_id
|
||||||
|
* @param string|null $caption Audio caption (0-1024 characters)
|
||||||
|
* @param string|null $parseMode Caption format (Markdown, MarkdownV2, HTML)
|
||||||
|
* @param int|null $duration Audio duration in seconds
|
||||||
|
*/
|
||||||
|
public function sendAudio(
|
||||||
|
TelegramChatId $chatId,
|
||||||
|
string $audio,
|
||||||
|
?string $caption = null,
|
||||||
|
?string $parseMode = null,
|
||||||
|
?int $duration = null
|
||||||
|
): TelegramResponse {
|
||||||
|
$payload = [
|
||||||
|
'chat_id' => $chatId->toString(),
|
||||||
|
'audio' => $audio,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($caption !== null) {
|
||||||
|
$payload['caption'] = $caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parseMode !== null) {
|
||||||
|
$payload['parse_mode'] = $parseMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($duration !== null) {
|
||||||
|
$payload['duration'] = $duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sendRequest($this->config->getSendAudioEndpoint(), $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send document
|
||||||
|
*
|
||||||
|
* @param TelegramChatId $chatId Recipient chat ID
|
||||||
|
* @param string $document File path or file_id
|
||||||
|
* @param string|null $caption Document caption (0-1024 characters)
|
||||||
|
* @param string|null $parseMode Caption format (Markdown, MarkdownV2, HTML)
|
||||||
|
* @param string|null $filename Custom filename for the document
|
||||||
|
*/
|
||||||
|
public function sendDocument(
|
||||||
|
TelegramChatId $chatId,
|
||||||
|
string $document,
|
||||||
|
?string $caption = null,
|
||||||
|
?string $parseMode = null,
|
||||||
|
?string $filename = null
|
||||||
|
): TelegramResponse {
|
||||||
|
$payload = [
|
||||||
|
'chat_id' => $chatId->toString(),
|
||||||
|
'document' => $document,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($caption !== null) {
|
||||||
|
$payload['caption'] = $caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parseMode !== null) {
|
||||||
|
$payload['parse_mode'] = $parseMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filename !== null) {
|
||||||
|
$payload['filename'] = $filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->sendRequest($this->config->getSendDocumentEndpoint(), $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send location
|
||||||
|
*
|
||||||
|
* @param TelegramChatId $chatId Recipient chat ID
|
||||||
|
* @param float $latitude Latitude of location
|
||||||
|
* @param float $longitude Longitude of location
|
||||||
|
*/
|
||||||
|
public function sendLocation(
|
||||||
|
TelegramChatId $chatId,
|
||||||
|
float $latitude,
|
||||||
|
float $longitude
|
||||||
|
): TelegramResponse {
|
||||||
|
$payload = [
|
||||||
|
'chat_id' => $chatId->toString(),
|
||||||
|
'latitude' => $latitude,
|
||||||
|
'longitude' => $longitude,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->sendRequest($this->config->getSendLocationEndpoint(), $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send request to Telegram API using HttpClient
|
||||||
|
*/
|
||||||
|
private function sendRequest(string $endpoint, array $payload): TelegramResponse
|
||||||
|
{
|
||||||
|
// Create JSON request
|
||||||
|
$request = ClientRequest::json(
|
||||||
|
method: Method::POST,
|
||||||
|
url: $endpoint,
|
||||||
|
data: $payload
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->httpClient->send($request);
|
||||||
|
|
||||||
|
if (!$response->isSuccessful()) {
|
||||||
|
throw new TelegramApiException(
|
||||||
|
"Telegram API request failed: HTTP {$response->status->value}",
|
||||||
|
$response->status->value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
if (!isset($data['ok']) || $data['ok'] !== true) {
|
||||||
|
$errorMessage = $data['description'] ?? 'Unknown error';
|
||||||
|
$errorCode = $data['error_code'] ?? 0;
|
||||||
|
|
||||||
|
throw new TelegramApiException(
|
||||||
|
"Telegram API error ({$errorCode}): {$errorMessage}",
|
||||||
|
$errorCode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract message ID from response
|
||||||
|
$messageId = $data['result']['message_id'] ?? null;
|
||||||
|
|
||||||
|
if ($messageId === null) {
|
||||||
|
throw new \RuntimeException('Telegram API response missing message ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TelegramResponse(
|
||||||
|
success: true,
|
||||||
|
messageId: TelegramMessageId::fromInt($messageId),
|
||||||
|
rawResponse: $data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram;
|
||||||
|
|
||||||
|
use App\Framework\Notification\Channels\Telegram\ValueObjects\TelegramBotToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Bot API configuration
|
||||||
|
*
|
||||||
|
* Holds credentials and settings for Telegram Bot API integration
|
||||||
|
*/
|
||||||
|
final readonly class TelegramConfig
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public TelegramBotToken $botToken,
|
||||||
|
public string $apiVersion = 'bot',
|
||||||
|
public string $baseUrl = 'https://api.telegram.org'
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create default configuration with hardcoded values
|
||||||
|
* TODO: Replace with actual bot token
|
||||||
|
*/
|
||||||
|
public static function createDefault(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
botToken: TelegramBotToken::fromString('8185213800:AAG92qxtLbDbFQ3CSDOTAPH3H9UCuFS8mSc')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getApiUrl(): string
|
||||||
|
{
|
||||||
|
return "{$this->baseUrl}/{$this->apiVersion}{$this->botToken}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSendMessageEndpoint(): string
|
||||||
|
{
|
||||||
|
return "{$this->getApiUrl()}/sendMessage";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGetUpdatesEndpoint(): string
|
||||||
|
{
|
||||||
|
return "{$this->getApiUrl()}/getUpdates";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGetMeEndpoint(): string
|
||||||
|
{
|
||||||
|
return "{$this->getApiUrl()}/getMe";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSetWebhookEndpoint(): string
|
||||||
|
{
|
||||||
|
return "{$this->getApiUrl()}/setWebhook";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeleteWebhookEndpoint(): string
|
||||||
|
{
|
||||||
|
return "{$this->getApiUrl()}/deleteWebhook";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAnswerCallbackQueryEndpoint(): string
|
||||||
|
{
|
||||||
|
return "{$this->getApiUrl()}/answerCallbackQuery";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEditMessageTextEndpoint(): string
|
||||||
|
{
|
||||||
|
return "{$this->getApiUrl()}/editMessageText";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSendPhotoEndpoint(): string
|
||||||
|
{
|
||||||
|
return "{$this->getApiUrl()}/sendPhoto";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSendVideoEndpoint(): string
|
||||||
|
{
|
||||||
|
return "{$this->getApiUrl()}/sendVideo";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSendAudioEndpoint(): string
|
||||||
|
{
|
||||||
|
return "{$this->getApiUrl()}/sendAudio";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSendDocumentEndpoint(): string
|
||||||
|
{
|
||||||
|
return "{$this->getApiUrl()}/sendDocument";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSendLocationEndpoint(): string
|
||||||
|
{
|
||||||
|
return "{$this->getApiUrl()}/sendLocation";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram;
|
||||||
|
|
||||||
|
use App\Framework\Attributes\Initializer;
|
||||||
|
use App\Framework\DI\Container;
|
||||||
|
use App\Framework\HttpClient\HttpClient;
|
||||||
|
use App\Framework\Notification\Channels\TelegramChannel;
|
||||||
|
use App\Framework\Notification\Channels\Telegram\Webhook\{CallbackRouter, TelegramWebhookEventHandler};
|
||||||
|
use App\Framework\Notification\Channels\Telegram\Webhook\Examples\{ApproveOrderHandler, RejectOrderHandler};
|
||||||
|
use App\Framework\Notification\Media\{MediaManager, Drivers\TelegramMediaDriver};
|
||||||
|
use App\Framework\Notification\ValueObjects\NotificationChannel;
|
||||||
|
use App\Framework\Logging\Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Notification Channel Initializer
|
||||||
|
*
|
||||||
|
* Registers Telegram notification components in the DI container
|
||||||
|
*/
|
||||||
|
final readonly class TelegramNotificationInitializer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Container $container
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Initializer]
|
||||||
|
public function initialize(): void
|
||||||
|
{
|
||||||
|
// Register Telegram Config
|
||||||
|
$this->container->singleton(
|
||||||
|
TelegramConfig::class,
|
||||||
|
fn () => TelegramConfig::createDefault()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register Chat ID Resolver
|
||||||
|
$this->container->singleton(
|
||||||
|
UserChatIdResolver::class,
|
||||||
|
fn () => FixedChatIdResolver::createDefault()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register Telegram Client
|
||||||
|
$this->container->singleton(
|
||||||
|
TelegramClient::class,
|
||||||
|
fn (Container $c) => new TelegramClient(
|
||||||
|
httpClient: $c->get(HttpClient::class),
|
||||||
|
config: $c->get(TelegramConfig::class)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register MediaManager (needs to be registered before TelegramChannel)
|
||||||
|
$this->container->singleton(
|
||||||
|
MediaManager::class,
|
||||||
|
function (Container $c) {
|
||||||
|
$mediaManager = new MediaManager();
|
||||||
|
|
||||||
|
// Register TelegramMediaDriver for Telegram channel
|
||||||
|
$telegramDriver = new TelegramMediaDriver(
|
||||||
|
client: $c->get(TelegramClient::class),
|
||||||
|
chatIdResolver: $c->get(UserChatIdResolver::class)
|
||||||
|
);
|
||||||
|
|
||||||
|
$mediaManager->registerDriver(
|
||||||
|
NotificationChannel::TELEGRAM,
|
||||||
|
$telegramDriver
|
||||||
|
);
|
||||||
|
|
||||||
|
return $mediaManager;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register Telegram Channel
|
||||||
|
$this->container->singleton(
|
||||||
|
TelegramChannel::class,
|
||||||
|
fn (Container $c) => new TelegramChannel(
|
||||||
|
client: $c->get(TelegramClient::class),
|
||||||
|
chatIdResolver: $c->get(UserChatIdResolver::class),
|
||||||
|
mediaManager: $c->get(MediaManager::class)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register Callback Router with example handlers
|
||||||
|
$this->container->singleton(
|
||||||
|
CallbackRouter::class,
|
||||||
|
function () {
|
||||||
|
$router = new CallbackRouter();
|
||||||
|
|
||||||
|
// Register example handlers
|
||||||
|
$router->register(new ApproveOrderHandler());
|
||||||
|
$router->register(new RejectOrderHandler());
|
||||||
|
|
||||||
|
// TODO: Register your custom handlers here
|
||||||
|
// $router->register(new YourCustomHandler());
|
||||||
|
|
||||||
|
return $router;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register Webhook Event Handler
|
||||||
|
$this->container->singleton(
|
||||||
|
TelegramWebhookEventHandler::class,
|
||||||
|
fn (Container $c) => new TelegramWebhookEventHandler(
|
||||||
|
telegramClient: $c->get(TelegramClient::class),
|
||||||
|
callbackRouter: $c->get(CallbackRouter::class),
|
||||||
|
logger: $c->get(Logger::class)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram;
|
||||||
|
|
||||||
|
use App\Framework\Notification\Channels\Telegram\ValueObjects\TelegramMessageId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram API Response Value Object
|
||||||
|
*/
|
||||||
|
final readonly class TelegramResponse
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public bool $success,
|
||||||
|
public TelegramMessageId $messageId,
|
||||||
|
public array $rawResponse = []
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSuccess(): bool
|
||||||
|
{
|
||||||
|
return $this->success;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram;
|
||||||
|
|
||||||
|
use App\Framework\Notification\Channels\Telegram\ValueObjects\TelegramChatId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves user IDs to Telegram chat IDs
|
||||||
|
*/
|
||||||
|
interface UserChatIdResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Resolve a user ID to a Telegram chat ID
|
||||||
|
*
|
||||||
|
* @param string $userId Application user ID
|
||||||
|
* @return TelegramChatId|null Chat ID or null if not found
|
||||||
|
*/
|
||||||
|
public function resolveChatId(string $userId): ?TelegramChatId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\ValueObjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Inline Keyboard Value Object
|
||||||
|
*
|
||||||
|
* Represents an inline keyboard with action buttons
|
||||||
|
*/
|
||||||
|
final readonly class InlineKeyboard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<array<InlineKeyboardButton>> $rows Rows of buttons
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public array $rows
|
||||||
|
) {
|
||||||
|
if (empty($rows)) {
|
||||||
|
throw new \InvalidArgumentException('Inline keyboard must have at least one row');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (empty($row)) {
|
||||||
|
throw new \InvalidArgumentException('Keyboard row cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($row as $button) {
|
||||||
|
if (!$button instanceof InlineKeyboardButton) {
|
||||||
|
throw new \InvalidArgumentException('All buttons must be InlineKeyboardButton instances');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create keyboard with a single row of buttons
|
||||||
|
*/
|
||||||
|
public static function singleRow(InlineKeyboardButton ...$buttons): self
|
||||||
|
{
|
||||||
|
return new self([$buttons]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create keyboard with multiple rows
|
||||||
|
*
|
||||||
|
* @param array<array<InlineKeyboardButton>> $rows
|
||||||
|
*/
|
||||||
|
public static function multiRow(array $rows): self
|
||||||
|
{
|
||||||
|
return new self($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to Telegram API format
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
$keyboard = [];
|
||||||
|
|
||||||
|
foreach ($this->rows as $row) {
|
||||||
|
$keyboardRow = [];
|
||||||
|
foreach ($row as $button) {
|
||||||
|
$keyboardRow[] = $button->toArray();
|
||||||
|
}
|
||||||
|
$keyboard[] = $keyboardRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['inline_keyboard' => $keyboard];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total number of buttons
|
||||||
|
*/
|
||||||
|
public function getButtonCount(): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
foreach ($this->rows as $row) {
|
||||||
|
$count += count($row);
|
||||||
|
}
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\ValueObjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Inline Keyboard Button Value Object
|
||||||
|
*
|
||||||
|
* Represents a single button in an inline keyboard
|
||||||
|
*/
|
||||||
|
final readonly class InlineKeyboardButton
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $text Button label (visible text)
|
||||||
|
* @param string|null $url HTTP(S) URL to open (mutually exclusive with callbackData)
|
||||||
|
* @param string|null $callbackData Data to send in callback query (mutually exclusive with url)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $text,
|
||||||
|
public ?string $url = null,
|
||||||
|
public ?string $callbackData = null
|
||||||
|
) {
|
||||||
|
if (empty($text)) {
|
||||||
|
throw new \InvalidArgumentException('Button text cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($url === null && $callbackData === null) {
|
||||||
|
throw new \InvalidArgumentException('Button must have either url or callbackData');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($url !== null && $callbackData !== null) {
|
||||||
|
throw new \InvalidArgumentException('Button cannot have both url and callbackData');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($callbackData !== null && strlen($callbackData) > 64) {
|
||||||
|
throw new \InvalidArgumentException('Callback data cannot exceed 64 bytes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create button with URL
|
||||||
|
*/
|
||||||
|
public static function withUrl(string $text, string $url): self
|
||||||
|
{
|
||||||
|
return new self(text: $text, url: $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create button with callback data
|
||||||
|
*/
|
||||||
|
public static function withCallback(string $text, string $callbackData): self
|
||||||
|
{
|
||||||
|
return new self(text: $text, callbackData: $callbackData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to Telegram API format
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
$button = ['text' => $this->text];
|
||||||
|
|
||||||
|
if ($this->url !== null) {
|
||||||
|
$button['url'] = $this->url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->callbackData !== null) {
|
||||||
|
$button['callback_data'] = $this->callbackData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $button;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isUrlButton(): bool
|
||||||
|
{
|
||||||
|
return $this->url !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCallbackButton(): bool
|
||||||
|
{
|
||||||
|
return $this->callbackData !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\ValueObjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Bot Token Value Object
|
||||||
|
*
|
||||||
|
* Format: {bot_id}:{auth_token}
|
||||||
|
* Example: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
*/
|
||||||
|
final readonly class TelegramBotToken
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $value
|
||||||
|
) {
|
||||||
|
if (empty($value)) {
|
||||||
|
throw new \InvalidArgumentException('Telegram bot token cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isValid($value)) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
"Invalid Telegram bot token format: {$value}. Expected format: {bot_id}:{auth_token}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isValid(string $token): bool
|
||||||
|
{
|
||||||
|
// Telegram bot token format: {bot_id}:{auth_token}
|
||||||
|
// bot_id: numeric
|
||||||
|
// auth_token: alphanumeric + dash + underscore
|
||||||
|
return preg_match('/^\d+:[A-Za-z0-9_-]+$/', $token) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBotId(): string
|
||||||
|
{
|
||||||
|
return explode(':', $this->value)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAuthToken(): string
|
||||||
|
{
|
||||||
|
return explode(':', $this->value)[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\ValueObjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Chat ID Value Object
|
||||||
|
*
|
||||||
|
* Can be:
|
||||||
|
* - User chat: numeric (positive or negative)
|
||||||
|
* - Group chat: numeric (negative)
|
||||||
|
* - Channel: @username or numeric
|
||||||
|
*/
|
||||||
|
final readonly class TelegramChatId
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $value
|
||||||
|
) {
|
||||||
|
if (empty($value)) {
|
||||||
|
throw new \InvalidArgumentException('Telegram chat ID cannot be empty');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromInt(int $value): self
|
||||||
|
{
|
||||||
|
return new self((string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isUsername(): bool
|
||||||
|
{
|
||||||
|
return str_starts_with($this->value, '@');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isNumeric(): bool
|
||||||
|
{
|
||||||
|
return is_numeric($this->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\ValueObjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Message ID Value Object
|
||||||
|
*
|
||||||
|
* Represents a unique message identifier returned by Telegram API
|
||||||
|
*/
|
||||||
|
final readonly class TelegramMessageId
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $value
|
||||||
|
) {
|
||||||
|
if ($value <= 0) {
|
||||||
|
throw new \InvalidArgumentException('Telegram message ID must be positive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromInt(int $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return (string) $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return (string) $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\Webhook;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Callback Handler Interface
|
||||||
|
*
|
||||||
|
* Implement this to handle specific callback button actions
|
||||||
|
*/
|
||||||
|
interface CallbackHandler
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the callback command this handler supports
|
||||||
|
* Example: "approve_order", "reject_order"
|
||||||
|
*/
|
||||||
|
public function getCommand(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the callback query
|
||||||
|
*
|
||||||
|
* @param TelegramCallbackQuery $callbackQuery The callback query
|
||||||
|
* @return CallbackResponse Response to send back to Telegram
|
||||||
|
*/
|
||||||
|
public function handle(TelegramCallbackQuery $callbackQuery): CallbackResponse;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\Webhook;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Callback Response Value Object
|
||||||
|
*
|
||||||
|
* Response to send after handling a callback query
|
||||||
|
*/
|
||||||
|
final readonly class CallbackResponse
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $text,
|
||||||
|
public bool $showAlert = false,
|
||||||
|
public ?string $editMessage = null
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a simple notification (toast)
|
||||||
|
*/
|
||||||
|
public static function notification(string $text): self
|
||||||
|
{
|
||||||
|
return new self(text: $text, showAlert: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an alert (popup)
|
||||||
|
*/
|
||||||
|
public static function alert(string $text): self
|
||||||
|
{
|
||||||
|
return new self(text: $text, showAlert: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a response that also edits the original message
|
||||||
|
*/
|
||||||
|
public static function withEdit(string $text, string $newMessage): self
|
||||||
|
{
|
||||||
|
return new self(text: $text, showAlert: false, editMessage: $newMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\Webhook;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Callback Router
|
||||||
|
*
|
||||||
|
* Routes callback queries to registered handlers
|
||||||
|
*/
|
||||||
|
final class CallbackRouter
|
||||||
|
{
|
||||||
|
/** @var array<string, CallbackHandler> */
|
||||||
|
private array $handlers = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback handler
|
||||||
|
*/
|
||||||
|
public function register(CallbackHandler $handler): void
|
||||||
|
{
|
||||||
|
$this->handlers[$handler->getCommand()] = $handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route callback query to appropriate handler
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException if no handler found
|
||||||
|
*/
|
||||||
|
public function route(TelegramCallbackQuery $callbackQuery): CallbackResponse
|
||||||
|
{
|
||||||
|
$command = $callbackQuery->getCommand();
|
||||||
|
|
||||||
|
if (!isset($this->handlers[$command])) {
|
||||||
|
throw new \RuntimeException("No handler registered for command: {$command}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->handlers[$command]->handle($callbackQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a handler is registered for a command
|
||||||
|
*/
|
||||||
|
public function hasHandler(string $command): bool
|
||||||
|
{
|
||||||
|
return isset($this->handlers[$command]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered commands
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public function getRegisteredCommands(): array
|
||||||
|
{
|
||||||
|
return array_keys($this->handlers);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\Webhook\Examples;
|
||||||
|
|
||||||
|
use App\Framework\Notification\Channels\Telegram\Webhook\{CallbackHandler, CallbackResponse, TelegramCallbackQuery};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example Callback Handler: Approve Order
|
||||||
|
*
|
||||||
|
* Demonstrates how to implement a custom callback handler
|
||||||
|
* for inline keyboard button clicks
|
||||||
|
*
|
||||||
|
* Usage in button:
|
||||||
|
* InlineKeyboardButton::withCallback('✅ Approve', 'approve_order_123')
|
||||||
|
*/
|
||||||
|
final readonly class ApproveOrderHandler implements CallbackHandler
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Command this handler responds to
|
||||||
|
*
|
||||||
|
* Callback data format: approve_order_{order_id}
|
||||||
|
* Example: approve_order_123
|
||||||
|
*/
|
||||||
|
public function getCommand(): string
|
||||||
|
{
|
||||||
|
return 'approve_order';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle order approval callback
|
||||||
|
*/
|
||||||
|
public function handle(TelegramCallbackQuery $callbackQuery): CallbackResponse
|
||||||
|
{
|
||||||
|
// Extract order ID from callback data
|
||||||
|
// e.g., "approve_order_123" → parameter is "123"
|
||||||
|
$orderId = $callbackQuery->getParameter();
|
||||||
|
|
||||||
|
if ($orderId === null) {
|
||||||
|
return CallbackResponse::alert('Invalid order ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual order approval logic
|
||||||
|
// $this->orderService->approve($orderId);
|
||||||
|
|
||||||
|
// Return response with message edit
|
||||||
|
return CallbackResponse::withEdit(
|
||||||
|
text: "✅ Order #{$orderId} approved!",
|
||||||
|
newMessage: "Order #{$orderId}\n\nStatus: ✅ *Approved*\nApproved by: User {$callbackQuery->fromUserId}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\Webhook\Examples;
|
||||||
|
|
||||||
|
use App\Framework\Notification\Channels\Telegram\Webhook\{CallbackHandler, CallbackResponse, TelegramCallbackQuery};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example Callback Handler: Reject Order
|
||||||
|
*
|
||||||
|
* Demonstrates callback handler with alert popup
|
||||||
|
*
|
||||||
|
* Usage in button:
|
||||||
|
* InlineKeyboardButton::withCallback('❌ Reject', 'reject_order_123')
|
||||||
|
*/
|
||||||
|
final readonly class RejectOrderHandler implements CallbackHandler
|
||||||
|
{
|
||||||
|
public function getCommand(): string
|
||||||
|
{
|
||||||
|
return 'reject_order';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(TelegramCallbackQuery $callbackQuery): CallbackResponse
|
||||||
|
{
|
||||||
|
$orderId = $callbackQuery->getParameter();
|
||||||
|
|
||||||
|
if ($orderId === null) {
|
||||||
|
return CallbackResponse::alert('Invalid order ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual order rejection logic
|
||||||
|
// $this->orderService->reject($orderId);
|
||||||
|
|
||||||
|
// Return alert popup with message edit
|
||||||
|
return CallbackResponse::withEdit(
|
||||||
|
text: "Order #{$orderId} has been rejected",
|
||||||
|
newMessage: "Order #{$orderId}\n\nStatus: ❌ *Rejected*\nRejected by: User {$callbackQuery->fromUserId}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
src/Framework/Notification/Channels/Telegram/Webhook/README.md
Normal file
198
src/Framework/Notification/Channels/Telegram/Webhook/README.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Telegram Webhook Integration
|
||||||
|
|
||||||
|
Complete webhook support for Telegram Bot API with framework integration.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ Framework `WebhookRequestHandler` integration
|
||||||
|
- ✅ Signature verification with `TelegramSignatureProvider`
|
||||||
|
- ✅ Automatic callback routing with `CallbackRouter`
|
||||||
|
- ✅ Event-driven architecture via `WebhookReceived` events
|
||||||
|
- ✅ Idempotency checking
|
||||||
|
- ✅ Example handlers included
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Telegram API → /webhooks/telegram → WebhookRequestHandler
|
||||||
|
↓
|
||||||
|
WebhookReceived Event
|
||||||
|
↓
|
||||||
|
TelegramWebhookEventHandler
|
||||||
|
↓
|
||||||
|
CallbackRouter
|
||||||
|
↓
|
||||||
|
ApproveOrderHandler / Custom Handlers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Setup Webhook
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php tests/debug/setup-telegram-webhook.php
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Generate a random secret token
|
||||||
|
- Configure Telegram webhook URL
|
||||||
|
- Display setup instructions
|
||||||
|
|
||||||
|
### 2. Add Secret to Environment
|
||||||
|
|
||||||
|
Add to `.env`:
|
||||||
|
```env
|
||||||
|
TELEGRAM_WEBHOOK_SECRET=your_generated_secret_token
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create Custom Handler
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Framework\Notification\Channels\Telegram\Webhook\{CallbackHandler, CallbackResponse, TelegramCallbackQuery};
|
||||||
|
|
||||||
|
final readonly class MyCustomHandler implements CallbackHandler
|
||||||
|
{
|
||||||
|
public function getCommand(): string
|
||||||
|
{
|
||||||
|
return 'my_action'; // Callback data: my_action_123
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(TelegramCallbackQuery $callbackQuery): CallbackResponse
|
||||||
|
{
|
||||||
|
$parameter = $callbackQuery->getParameter(); // "123"
|
||||||
|
|
||||||
|
// Your business logic here
|
||||||
|
|
||||||
|
return CallbackResponse::notification('Action completed!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Register Handler
|
||||||
|
|
||||||
|
In `TelegramNotificationInitializer.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$router->register(new MyCustomHandler());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Value Objects
|
||||||
|
|
||||||
|
- **`TelegramUpdate`** - Incoming webhook update
|
||||||
|
- **`TelegramMessage`** - Message data
|
||||||
|
- **`TelegramCallbackQuery`** - Callback button click
|
||||||
|
- **`CallbackResponse`** - Response to send back
|
||||||
|
|
||||||
|
### Interfaces
|
||||||
|
|
||||||
|
- **`CallbackHandler`** - Implement for custom handlers
|
||||||
|
|
||||||
|
### Classes
|
||||||
|
|
||||||
|
- **`CallbackRouter`** - Routes callbacks to handlers
|
||||||
|
- **`TelegramWebhookController`** - Webhook endpoint
|
||||||
|
- **`TelegramWebhookEventHandler`** - Event processor
|
||||||
|
- **`TelegramSignatureProvider`** - Security verification
|
||||||
|
- **`TelegramWebhookProvider`** - Provider factory
|
||||||
|
|
||||||
|
## Callback Data Format
|
||||||
|
|
||||||
|
Telegram callback buttons use `data` field (max 64 bytes).
|
||||||
|
|
||||||
|
**Recommended format**: `{command}_{parameter}`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `approve_order_123` → command: `approve_order`, parameter: `123`
|
||||||
|
- `delete_user_456` → command: `delete_user`, parameter: `456`
|
||||||
|
- `toggle_setting_notifications` → command: `toggle_setting`, parameter: `notifications`
|
||||||
|
|
||||||
|
## Response Types
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Simple notification (toast message)
|
||||||
|
CallbackResponse::notification('Action completed!');
|
||||||
|
|
||||||
|
// Alert popup
|
||||||
|
CallbackResponse::alert('Are you sure?');
|
||||||
|
|
||||||
|
// Notification + edit message
|
||||||
|
CallbackResponse::withEdit(
|
||||||
|
text: 'Order approved!',
|
||||||
|
newMessage: 'Order #123\nStatus: ✅ Approved'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Send Test Message with Buttons
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php tests/debug/test-telegram-webhook-buttons.php
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitor Webhook Requests
|
||||||
|
|
||||||
|
Check logs for:
|
||||||
|
- `Telegram webhook received`
|
||||||
|
- `Processing callback query`
|
||||||
|
- `Callback query processed successfully`
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **Secret Token**: Random token sent in `X-Telegram-Bot-Api-Secret-Token` header
|
||||||
|
- **HTTPS Required**: Telegram requires HTTPS for webhooks
|
||||||
|
- **Signature Verification**: Automatic via `TelegramSignatureProvider`
|
||||||
|
- **Idempotency**: Duplicate requests are detected and ignored
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Webhook not receiving updates
|
||||||
|
|
||||||
|
1. Check webhook is configured:
|
||||||
|
```bash
|
||||||
|
curl https://api.telegram.org/bot{BOT_TOKEN}/getWebhookInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify URL is publicly accessible via HTTPS
|
||||||
|
|
||||||
|
3. Check `TELEGRAM_WEBHOOK_SECRET` is set in `.env`
|
||||||
|
|
||||||
|
### Callback buttons not working
|
||||||
|
|
||||||
|
1. Ensure webhook is set (not using getUpdates polling)
|
||||||
|
2. Check callback handler is registered in `CallbackRouter`
|
||||||
|
3. Verify callback data format matches handler command
|
||||||
|
4. Check logs for error messages
|
||||||
|
|
||||||
|
### "No handler registered for command"
|
||||||
|
|
||||||
|
The callback command from button doesn't match any registered handler.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Button: `approve_order_123`
|
||||||
|
- Extracted command: `approve_order`
|
||||||
|
- Needs handler with `getCommand() === 'approve_order'`
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See `Examples/` directory:
|
||||||
|
- `ApproveOrderHandler.php` - Order approval with message edit
|
||||||
|
- `RejectOrderHandler.php` - Order rejection with alert
|
||||||
|
|
||||||
|
## Framework Integration
|
||||||
|
|
||||||
|
This implementation uses:
|
||||||
|
- **Framework Webhook Module** - `App\Framework\Webhook\*`
|
||||||
|
- **Event System** - `WebhookReceived` events
|
||||||
|
- **DI Container** - Automatic registration
|
||||||
|
- **HttpClient** - API communication
|
||||||
|
- **Logger** - Webhook event logging
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- Implement rich media support (photos, documents)
|
||||||
|
- Add message editing capabilities
|
||||||
|
- Extend with more callback handlers
|
||||||
|
- Add webhook retry logic
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\Webhook;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Callback Query Value Object
|
||||||
|
*
|
||||||
|
* Represents a callback button click
|
||||||
|
*/
|
||||||
|
final readonly class TelegramCallbackQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public string $data,
|
||||||
|
public int $chatId,
|
||||||
|
public int $messageId,
|
||||||
|
public ?int $fromUserId = null,
|
||||||
|
public ?string $fromUsername = null
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
id: $data['id'],
|
||||||
|
data: $data['data'],
|
||||||
|
chatId: $data['message']['chat']['id'],
|
||||||
|
messageId: $data['message']['message_id'],
|
||||||
|
fromUserId: $data['from']['id'] ?? null,
|
||||||
|
fromUsername: $data['from']['username'] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse callback data as command with optional parameters
|
||||||
|
* Example: "approve_order_123" → ['approve_order', '123']
|
||||||
|
*/
|
||||||
|
public function parseCommand(): array
|
||||||
|
{
|
||||||
|
$parts = explode('_', $this->data);
|
||||||
|
if (count($parts) < 2) {
|
||||||
|
return [$this->data, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$command = implode('_', array_slice($parts, 0, -1));
|
||||||
|
$parameter = end($parts);
|
||||||
|
|
||||||
|
return [$command, $parameter];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCommand(): string
|
||||||
|
{
|
||||||
|
return $this->parseCommand()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getParameter(): ?string
|
||||||
|
{
|
||||||
|
return $this->parseCommand()[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\Webhook;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Message Value Object
|
||||||
|
*
|
||||||
|
* Represents an incoming message
|
||||||
|
*/
|
||||||
|
final readonly class TelegramMessage
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $messageId,
|
||||||
|
public int $chatId,
|
||||||
|
public string $text,
|
||||||
|
public ?int $fromUserId = null,
|
||||||
|
public ?string $fromUsername = null,
|
||||||
|
public ?string $fromFirstName = null
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
messageId: $data['message_id'],
|
||||||
|
chatId: $data['chat']['id'],
|
||||||
|
text: $data['text'] ?? '',
|
||||||
|
fromUserId: $data['from']['id'] ?? null,
|
||||||
|
fromUsername: $data['from']['username'] ?? null,
|
||||||
|
fromFirstName: $data['from']['first_name'] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\Webhook;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Update Value Object
|
||||||
|
*
|
||||||
|
* Represents an incoming update from Telegram (message, callback query, etc.)
|
||||||
|
*/
|
||||||
|
final readonly class TelegramUpdate
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $updateId,
|
||||||
|
public ?TelegramMessage $message = null,
|
||||||
|
public ?TelegramCallbackQuery $callbackQuery = null,
|
||||||
|
public array $rawData = []
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
updateId: $data['update_id'],
|
||||||
|
message: isset($data['message']) ? TelegramMessage::fromArray($data['message']) : null,
|
||||||
|
callbackQuery: isset($data['callback_query']) ? TelegramCallbackQuery::fromArray($data['callback_query']) : null,
|
||||||
|
rawData: $data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isMessage(): bool
|
||||||
|
{
|
||||||
|
return $this->message !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCallbackQuery(): bool
|
||||||
|
{
|
||||||
|
return $this->callbackQuery !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getType(): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$this->isMessage() => 'message',
|
||||||
|
$this->isCallbackQuery() => 'callback_query',
|
||||||
|
default => 'unknown'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\Webhook;
|
||||||
|
|
||||||
|
use App\Framework\Attributes\Route;
|
||||||
|
use App\Framework\Http\HttpRequest;
|
||||||
|
use App\Framework\Http\Method;
|
||||||
|
use App\Framework\Router\Result\JsonResult;
|
||||||
|
use App\Framework\Webhook\Attributes\WebhookEndpoint;
|
||||||
|
use App\Framework\Webhook\Processing\WebhookRequestHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Webhook Controller
|
||||||
|
*
|
||||||
|
* Receives webhook updates from Telegram Bot API
|
||||||
|
* Uses framework's WebhookRequestHandler for automatic processing
|
||||||
|
*/
|
||||||
|
final readonly class TelegramWebhookController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private WebhookRequestHandler $webhookHandler
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming Telegram webhook updates
|
||||||
|
*
|
||||||
|
* Telegram sends updates for:
|
||||||
|
* - New messages
|
||||||
|
* - Callback queries (inline keyboard button clicks)
|
||||||
|
* - Edited messages
|
||||||
|
* - Channel posts
|
||||||
|
* - And more...
|
||||||
|
*
|
||||||
|
* @see https://core.telegram.org/bots/api#update
|
||||||
|
*/
|
||||||
|
#[Route(path: '/webhooks/telegram', method: Method::POST)]
|
||||||
|
#[WebhookEndpoint(
|
||||||
|
provider: 'telegram',
|
||||||
|
events: ['message', 'callback_query', 'edited_message'],
|
||||||
|
async: false, // Process synchronously for immediate callback responses
|
||||||
|
timeout: 10,
|
||||||
|
idempotent: true
|
||||||
|
)]
|
||||||
|
public function handleWebhook(HttpRequest $request): JsonResult
|
||||||
|
{
|
||||||
|
// Get secret token from environment
|
||||||
|
$secretToken = $_ENV['TELEGRAM_WEBHOOK_SECRET'] ?? '';
|
||||||
|
|
||||||
|
if (empty($secretToken)) {
|
||||||
|
return new JsonResult([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Webhook secret not configured',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let framework's WebhookRequestHandler do the heavy lifting:
|
||||||
|
// - Signature verification
|
||||||
|
// - Idempotency checking
|
||||||
|
// - Event dispatching
|
||||||
|
// - Error handling
|
||||||
|
return $this->webhookHandler->handle(
|
||||||
|
request: $request,
|
||||||
|
provider: TelegramWebhookProvider::create(),
|
||||||
|
secret: $secretToken,
|
||||||
|
allowedEvents: ['message', 'callback_query', 'edited_message']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\Webhook;
|
||||||
|
|
||||||
|
use App\Framework\Attributes\EventHandler;
|
||||||
|
use App\Framework\Logging\Logger;
|
||||||
|
use App\Framework\Notification\Channels\Telegram\TelegramClient;
|
||||||
|
use App\Framework\Webhook\Events\WebhookReceived;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Webhook Event Handler
|
||||||
|
*
|
||||||
|
* Listens for WebhookReceived events from Telegram and processes them
|
||||||
|
* Handles callback queries from inline keyboards
|
||||||
|
*/
|
||||||
|
#[EventHandler]
|
||||||
|
final readonly class TelegramWebhookEventHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TelegramClient $telegramClient,
|
||||||
|
private CallbackRouter $callbackRouter,
|
||||||
|
private Logger $logger
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming Telegram webhook
|
||||||
|
*
|
||||||
|
* Only processes webhooks from Telegram provider
|
||||||
|
*/
|
||||||
|
public function handle(WebhookReceived $event): void
|
||||||
|
{
|
||||||
|
// Only handle Telegram webhooks
|
||||||
|
if ($event->provider->name !== 'telegram') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Telegram update from payload
|
||||||
|
$updateData = $event->payload->getData();
|
||||||
|
$update = TelegramUpdate::fromArray($updateData);
|
||||||
|
|
||||||
|
$this->logger->info('Telegram webhook received', [
|
||||||
|
'update_id' => $update->updateId,
|
||||||
|
'has_message' => $update->isMessage(),
|
||||||
|
'has_callback' => $update->isCallbackQuery(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Handle callback query (inline keyboard button click)
|
||||||
|
if ($update->isCallbackQuery()) {
|
||||||
|
$this->handleCallbackQuery($update->callbackQuery);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle regular message
|
||||||
|
if ($update->isMessage()) {
|
||||||
|
$this->handleMessage($update->message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->warning('Unknown Telegram update type', [
|
||||||
|
'update_id' => $update->updateId,
|
||||||
|
'raw_data' => $update->rawData,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle callback query from inline keyboard
|
||||||
|
*/
|
||||||
|
private function handleCallbackQuery(TelegramCallbackQuery $callbackQuery): void
|
||||||
|
{
|
||||||
|
$this->logger->info('Processing callback query', [
|
||||||
|
'callback_id' => $callbackQuery->id,
|
||||||
|
'data' => $callbackQuery->data,
|
||||||
|
'command' => $callbackQuery->getCommand(),
|
||||||
|
'parameter' => $callbackQuery->getParameter(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Route to appropriate handler
|
||||||
|
$response = $this->callbackRouter->route($callbackQuery);
|
||||||
|
|
||||||
|
// Answer callback query (shows notification/alert to user)
|
||||||
|
$this->telegramClient->answerCallbackQuery($callbackQuery->id, $response);
|
||||||
|
|
||||||
|
// If response includes message edit, update the message
|
||||||
|
if ($response->editMessage !== null) {
|
||||||
|
$this->telegramClient->editMessageText(
|
||||||
|
chatId: $callbackQuery->chatId,
|
||||||
|
messageId: $callbackQuery->messageId,
|
||||||
|
text: $response->editMessage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('Callback query processed successfully', [
|
||||||
|
'callback_id' => $callbackQuery->id,
|
||||||
|
'response_type' => $response->showAlert ? 'alert' : 'notification',
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
// No handler found for this command
|
||||||
|
$this->logger->warning('No handler for callback command', [
|
||||||
|
'callback_id' => $callbackQuery->id,
|
||||||
|
'command' => $callbackQuery->getCommand(),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send generic response
|
||||||
|
$fallbackResponse = CallbackResponse::notification(
|
||||||
|
'This action is not available right now.'
|
||||||
|
);
|
||||||
|
$this->telegramClient->answerCallbackQuery($callbackQuery->id, $fallbackResponse);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error('Error processing callback query', [
|
||||||
|
'callback_id' => $callbackQuery->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send error response
|
||||||
|
$errorResponse = CallbackResponse::alert(
|
||||||
|
'An error occurred processing your request.'
|
||||||
|
);
|
||||||
|
$this->telegramClient->answerCallbackQuery($callbackQuery->id, $errorResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle regular message
|
||||||
|
*
|
||||||
|
* You can extend this to process incoming messages
|
||||||
|
* For now, we just log it
|
||||||
|
*/
|
||||||
|
private function handleMessage(TelegramMessage $message): void
|
||||||
|
{
|
||||||
|
$this->logger->info('Telegram message received', [
|
||||||
|
'message_id' => $message->messageId->value,
|
||||||
|
'chat_id' => $message->chatId->toString(),
|
||||||
|
'text' => $message->text,
|
||||||
|
'from_user' => $message->fromUserId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// TODO: Add message handling logic if needed
|
||||||
|
// For example: command processing, chat bot responses, etc.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\Telegram\Webhook;
|
||||||
|
|
||||||
|
use App\Framework\Webhook\ValueObjects\WebhookProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Webhook Provider Factory
|
||||||
|
*
|
||||||
|
* Creates WebhookProvider configured for Telegram Bot API webhooks
|
||||||
|
*/
|
||||||
|
final readonly class TelegramWebhookProvider
|
||||||
|
{
|
||||||
|
public static function create(): WebhookProvider
|
||||||
|
{
|
||||||
|
return new WebhookProvider(
|
||||||
|
name: 'telegram',
|
||||||
|
signatureAlgorithm: 'token',
|
||||||
|
signatureHeader: 'X-Telegram-Bot-Api-Secret-Token',
|
||||||
|
eventTypeHeader: 'X-Telegram-Update-Type'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/Framework/Notification/Channels/TelegramChannel.php
Normal file
97
src/Framework/Notification/Channels/TelegramChannel.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels;
|
||||||
|
|
||||||
|
use App\Framework\Notification\Channels\Telegram\{TelegramClient, UserChatIdResolver};
|
||||||
|
use App\Framework\Notification\{Notification, NotificationChannelInterface};
|
||||||
|
use App\Framework\Notification\Media\MediaManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram notification channel
|
||||||
|
*
|
||||||
|
* Sends notifications via Telegram Bot API
|
||||||
|
*/
|
||||||
|
final readonly class TelegramChannel implements NotificationChannelInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TelegramClient $client,
|
||||||
|
private UserChatIdResolver $chatIdResolver,
|
||||||
|
public MediaManager $mediaManager
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(Notification $notification): bool
|
||||||
|
{
|
||||||
|
// Resolve chat ID from user ID
|
||||||
|
$chatId = $this->chatIdResolver->resolveChatId($notification->userId);
|
||||||
|
|
||||||
|
if ($chatId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format message
|
||||||
|
$text = $this->formatMessage($notification);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send message via Telegram
|
||||||
|
$response = $this->client->sendMessage(
|
||||||
|
chatId: $chatId,
|
||||||
|
text: $text,
|
||||||
|
parseMode: 'Markdown' // Support for basic formatting
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response->isSuccess();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Log error but don't throw - notification failures should be graceful
|
||||||
|
error_log("Telegram notification failed: {$e->getMessage()}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format notification as Telegram message with Markdown
|
||||||
|
*/
|
||||||
|
private function formatMessage(Notification $notification): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
// Title in bold
|
||||||
|
if (!empty($notification->title)) {
|
||||||
|
$parts[] = "*{$this->escapeMarkdown($notification->title)}*";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body
|
||||||
|
if (!empty($notification->body)) {
|
||||||
|
$parts[] = $this->escapeMarkdown($notification->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action text with link
|
||||||
|
if (!empty($notification->actionText) && !empty($notification->actionUrl)) {
|
||||||
|
$parts[] = "[{$this->escapeMarkdown($notification->actionText)}]({$notification->actionUrl})";
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n\n", $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape special characters for Telegram Markdown
|
||||||
|
*/
|
||||||
|
private function escapeMarkdown(string $text): string
|
||||||
|
{
|
||||||
|
// Escape special Markdown characters
|
||||||
|
$specialChars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
|
||||||
|
|
||||||
|
foreach ($specialChars as $char) {
|
||||||
|
$text = str_replace($char, '\\' . $char, $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return 'telegram';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\WhatsApp;
|
||||||
|
|
||||||
|
use App\Framework\Core\ValueObjects\PhoneNumber;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed phone number resolver
|
||||||
|
*
|
||||||
|
* Returns a single hardcoded phone number for all users
|
||||||
|
* Useful for development/testing or single-user scenarios
|
||||||
|
*/
|
||||||
|
final readonly class FixedPhoneNumberResolver implements UserPhoneNumberResolver
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PhoneNumber $phoneNumber
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always returns the same phone number regardless of user ID
|
||||||
|
*/
|
||||||
|
public function resolvePhoneNumber(string $userId): ?PhoneNumber
|
||||||
|
{
|
||||||
|
return $this->phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create resolver with default phone number
|
||||||
|
*/
|
||||||
|
public static function createDefault(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
phoneNumber: PhoneNumber::fromString('+4917941122213')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\WhatsApp;
|
||||||
|
|
||||||
|
use App\Framework\Core\ValueObjects\PhoneNumber;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User phone number resolver interface
|
||||||
|
*
|
||||||
|
* Resolves a user ID to their WhatsApp phone number
|
||||||
|
*/
|
||||||
|
interface UserPhoneNumberResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Resolve user ID to phone number
|
||||||
|
*
|
||||||
|
* @param string $userId User identifier
|
||||||
|
* @return PhoneNumber|null Phone number if found, null otherwise
|
||||||
|
*/
|
||||||
|
public function resolvePhoneNumber(string $userId): ?PhoneNumber;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\WhatsApp\ValueObjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsApp Business Account ID value object
|
||||||
|
*
|
||||||
|
* Identifies a WhatsApp Business Account in the WhatsApp Business API
|
||||||
|
*/
|
||||||
|
final readonly class WhatsAppBusinessAccountId
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $value
|
||||||
|
) {
|
||||||
|
if (empty($value)) {
|
||||||
|
throw new \InvalidArgumentException('WhatsApp Business Account ID cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctype_digit($value)) {
|
||||||
|
throw new \InvalidArgumentException("Invalid WhatsApp Business Account ID format: {$value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\WhatsApp\ValueObjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsApp message ID value object
|
||||||
|
*
|
||||||
|
* Unique identifier for a WhatsApp message returned by the API
|
||||||
|
*/
|
||||||
|
final readonly class WhatsAppMessageId
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $value
|
||||||
|
) {
|
||||||
|
if (empty($value)) {
|
||||||
|
throw new \InvalidArgumentException('WhatsApp Message ID cannot be empty');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\WhatsApp\ValueObjects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsApp template name/ID value object
|
||||||
|
*
|
||||||
|
* Represents a pre-approved WhatsApp Business template
|
||||||
|
*/
|
||||||
|
final readonly class WhatsAppTemplateId
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $value
|
||||||
|
) {
|
||||||
|
if (empty($value)) {
|
||||||
|
throw new \InvalidArgumentException('WhatsApp Template ID cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template names must be lowercase alphanumeric with underscores
|
||||||
|
if (!preg_match('/^[a-z0-9_]+$/', $value)) {
|
||||||
|
throw new \InvalidArgumentException("Invalid WhatsApp Template ID format: {$value}. Must be lowercase alphanumeric with underscores");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromString(string $value): self
|
||||||
|
{
|
||||||
|
return new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\WhatsApp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsApp API exception
|
||||||
|
*
|
||||||
|
* Thrown when WhatsApp Business API returns an error
|
||||||
|
*/
|
||||||
|
final class WhatsAppApiException extends \RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message,
|
||||||
|
int $httpStatusCode = 0,
|
||||||
|
?\Throwable $previous = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message, $httpStatusCode, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHttpStatusCode(): int
|
||||||
|
{
|
||||||
|
return $this->getCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/Framework/Notification/Channels/WhatsApp/WhatsAppClient.php
Normal file
138
src/Framework/Notification/Channels/WhatsApp/WhatsAppClient.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\WhatsApp;
|
||||||
|
|
||||||
|
use App\Framework\Core\ValueObjects\PhoneNumber;
|
||||||
|
use App\Framework\Http\Headers;
|
||||||
|
use App\Framework\Http\Method;
|
||||||
|
use App\Framework\HttpClient\ClientRequest;
|
||||||
|
use App\Framework\HttpClient\HttpClient;
|
||||||
|
use App\Framework\Notification\Channels\WhatsApp\ValueObjects\WhatsAppMessageId;
|
||||||
|
use App\Framework\Notification\Channels\WhatsApp\ValueObjects\WhatsAppTemplateId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsApp Business API client
|
||||||
|
*
|
||||||
|
* Handles communication with WhatsApp Business API using framework's HttpClient
|
||||||
|
*/
|
||||||
|
final readonly class WhatsAppClient
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private HttpClient $httpClient,
|
||||||
|
private WhatsAppConfig $config
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a text message
|
||||||
|
*/
|
||||||
|
public function sendTextMessage(PhoneNumber $to, string $message): WhatsAppResponse
|
||||||
|
{
|
||||||
|
$payload = [
|
||||||
|
'messaging_product' => 'whatsapp',
|
||||||
|
'to' => $to->toString(),
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => [
|
||||||
|
'body' => $message,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->sendRequest($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a template message
|
||||||
|
*
|
||||||
|
* @param PhoneNumber $to Recipient phone number
|
||||||
|
* @param WhatsAppTemplateId $templateId Template name
|
||||||
|
* @param string $languageCode Language code (e.g., 'en_US', 'de_DE')
|
||||||
|
* @param array<string> $parameters Template parameters
|
||||||
|
*/
|
||||||
|
public function sendTemplateMessage(
|
||||||
|
PhoneNumber $to,
|
||||||
|
WhatsAppTemplateId $templateId,
|
||||||
|
string $languageCode,
|
||||||
|
array $parameters = []
|
||||||
|
): WhatsAppResponse {
|
||||||
|
$components = [];
|
||||||
|
|
||||||
|
if (!empty($parameters)) {
|
||||||
|
$components[] = [
|
||||||
|
'type' => 'body',
|
||||||
|
'parameters' => array_map(
|
||||||
|
fn ($value) => ['type' => 'text', 'text' => $value],
|
||||||
|
$parameters
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'messaging_product' => 'whatsapp',
|
||||||
|
'to' => $to->toString(),
|
||||||
|
'type' => 'template',
|
||||||
|
'template' => [
|
||||||
|
'name' => $templateId->toString(),
|
||||||
|
'language' => [
|
||||||
|
'code' => $languageCode,
|
||||||
|
],
|
||||||
|
'components' => $components,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->sendRequest($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send request to WhatsApp API using HttpClient
|
||||||
|
*/
|
||||||
|
private function sendRequest(array $payload): WhatsAppResponse
|
||||||
|
{
|
||||||
|
// Create JSON request with Authorization header
|
||||||
|
$request = ClientRequest::json(
|
||||||
|
method: Method::POST,
|
||||||
|
url: $this->config->getMessagesEndpoint(),
|
||||||
|
data: $payload
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add Authorization header
|
||||||
|
$headers = $request->headers->with('Authorization', 'Bearer ' . $this->config->accessToken);
|
||||||
|
|
||||||
|
// Update request with new headers
|
||||||
|
$request = new ClientRequest(
|
||||||
|
method: $request->method,
|
||||||
|
url: $request->url,
|
||||||
|
headers: $headers,
|
||||||
|
body: $request->body,
|
||||||
|
options: $request->options
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->httpClient->send($request);
|
||||||
|
|
||||||
|
if (!$response->isSuccessful()) {
|
||||||
|
$data = $response->isJson() ? $response->json() : [];
|
||||||
|
$errorMessage = $data['error']['message'] ?? 'Unknown error';
|
||||||
|
$errorCode = $data['error']['code'] ?? 0;
|
||||||
|
|
||||||
|
throw new WhatsAppApiException(
|
||||||
|
"WhatsApp API error ({$errorCode}): {$errorMessage}",
|
||||||
|
$response->status->value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse successful response
|
||||||
|
$data = $response->json();
|
||||||
|
$messageId = $data['messages'][0]['id'] ?? null;
|
||||||
|
|
||||||
|
if ($messageId === null) {
|
||||||
|
throw new \RuntimeException('WhatsApp API response missing message ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WhatsAppResponse(
|
||||||
|
success: true,
|
||||||
|
messageId: WhatsAppMessageId::fromString($messageId),
|
||||||
|
rawResponse: $data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\WhatsApp;
|
||||||
|
|
||||||
|
use App\Framework\Notification\Channels\WhatsApp\ValueObjects\WhatsAppBusinessAccountId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsApp Business API configuration
|
||||||
|
*
|
||||||
|
* Holds credentials and settings for WhatsApp Business API integration
|
||||||
|
*/
|
||||||
|
final readonly class WhatsAppConfig
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $accessToken,
|
||||||
|
public string $phoneNumberId,
|
||||||
|
public WhatsAppBusinessAccountId $businessAccountId,
|
||||||
|
public string $apiVersion = 'v18.0',
|
||||||
|
public string $baseUrl = 'https://graph.facebook.com'
|
||||||
|
) {
|
||||||
|
if (empty($accessToken)) {
|
||||||
|
throw new \InvalidArgumentException('WhatsApp access token cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($phoneNumberId)) {
|
||||||
|
throw new \InvalidArgumentException('WhatsApp phone number ID cannot be empty');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create default configuration with hardcoded values
|
||||||
|
*/
|
||||||
|
public static function createDefault(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
accessToken: 'EAAPOiK6axoUBP509u1r1dZBSX4p1947wxDG5HUh6LYbd0tak52ZCjozuaLHn1bGixZCjEqQdW4VrzUIDZADxhZARgjtrhCE2r0f1ByqTjzZBTUdaVHvcg9CmxLxpMMWGdyytIosYHcfbXUeCO3oEmJZCXDd9Oy13eAhlBZBYqZALoZA5p1Smek1IVDOLpqKBIjA0qCeuT70Cj6EXXPVZAqrDP1a71eBrwZA0dQqQeZAerzW3LQJaC',
|
||||||
|
phoneNumberId: '107051338692505',
|
||||||
|
businessAccountId: WhatsAppBusinessAccountId::fromString('107051338692505'),
|
||||||
|
apiVersion: 'v18.0'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getApiUrl(): string
|
||||||
|
{
|
||||||
|
return "{$this->baseUrl}/{$this->apiVersion}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMessagesEndpoint(): string
|
||||||
|
{
|
||||||
|
return "{$this->getApiUrl()}/{$this->phoneNumberId}/messages";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\WhatsApp;
|
||||||
|
|
||||||
|
use App\Framework\Attributes\Initializer;
|
||||||
|
use App\Framework\DI\Container;
|
||||||
|
use App\Framework\HttpClient\HttpClient;
|
||||||
|
use App\Framework\Notification\Channels\WhatsAppChannel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsApp Notification Channel Initializer
|
||||||
|
*
|
||||||
|
* Registers WhatsApp notification components in the DI container
|
||||||
|
*/
|
||||||
|
final readonly class WhatsAppNotificationInitializer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Container $container
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Initializer]
|
||||||
|
public function initialize(): void
|
||||||
|
{
|
||||||
|
// Register WhatsApp Config
|
||||||
|
$this->container->singleton(
|
||||||
|
WhatsAppConfig::class,
|
||||||
|
fn () => WhatsAppConfig::createDefault()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register Phone Number Resolver
|
||||||
|
$this->container->singleton(
|
||||||
|
UserPhoneNumberResolver::class,
|
||||||
|
fn () => FixedPhoneNumberResolver::createDefault()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register WhatsApp Client
|
||||||
|
$this->container->singleton(
|
||||||
|
WhatsAppClient::class,
|
||||||
|
fn (Container $c) => new WhatsAppClient(
|
||||||
|
httpClient: $c->get(HttpClient::class),
|
||||||
|
config: $c->get(WhatsAppConfig::class)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register WhatsApp Channel
|
||||||
|
$this->container->singleton(
|
||||||
|
WhatsAppChannel::class,
|
||||||
|
fn (Container $c) => new WhatsAppChannel(
|
||||||
|
client: $c->get(WhatsAppClient::class),
|
||||||
|
phoneNumberResolver: $c->get(UserPhoneNumberResolver::class)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Framework\Notification\Channels\WhatsApp;
|
||||||
|
|
||||||
|
use App\Framework\Notification\Channels\WhatsApp\ValueObjects\WhatsAppMessageId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsApp API response value object
|
||||||
|
*
|
||||||
|
* Represents a successful response from the WhatsApp Business API
|
||||||
|
*/
|
||||||
|
final readonly class WhatsAppResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param bool $success Whether the request was successful
|
||||||
|
* @param WhatsAppMessageId $messageId WhatsApp message ID
|
||||||
|
* @param array<string, mixed> $rawResponse Raw API response data
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public bool $success,
|
||||||
|
public WhatsAppMessageId $messageId,
|
||||||
|
public array $rawResponse = []
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSuccessful(): bool
|
||||||
|
{
|
||||||
|
return $this->success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMessageId(): WhatsAppMessageId
|
||||||
|
{
|
||||||
|
return $this->messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'success' => $this->success,
|
||||||
|
'message_id' => $this->messageId->toString(),
|
||||||
|
'raw_response' => $this->rawResponse,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user