Commit 3bff7dd8 authored by Administrator's avatar Administrator

Update 94 files via Son of Anton

parents
<?php
declare(strict_types=1);
use Engine\Core\App;
use Engine\Core\Config;
use Engine\Core\Container;
use Engine\Core\Router;
use Engine\Database\Connection;
use Engine\Auth\SessionManager;
use Engine\Auth\Authenticator;
use Engine\Auth\PasswordHasher;
use Engine\Auth\PermissionEngine;
use Engine\Auth\RateLimiter;
use Engine\Audit\AuditLogger;
use Engine\Events\EventDispatcher;
use Engine\StateMachine\StateMachine;
use Engine\Notifications\NotificationManager;
use Engine\Calculation\CalculationEngine;
use Engine\Validation\Validator;
use Engine\FileStorage\FileManager;
use Engine\Scheduler\JobRunner;
use Engine\Search\SearchEngine;
use Engine\Export\ExportManager;
use Engine\Template\TemplateEngine;
$container = Container::getInstance();
// Config
$container->singleton(Config::class, function () {
return new Config(ROOT_PATH . '/config');
});
// Database
$container->singleton(Connection::class, function () use ($container) {
$cfg = $container->resolve(Config::class)->get('database');
return new Connection($cfg);
});
// Template Engine
$container->singleton(TemplateEngine::class, function () {
return new TemplateEngine(ROOT_PATH . '/templates', ROOT_PATH . '/storage/cache/templates');
});
// Password Hasher
$container->singleton(PasswordHasher::class, fn() => new PasswordHasher(12));
// Session Manager
$container->singleton(SessionManager::class, function () use ($container) {
return new SessionManager($container->resolve(Connection::class));
});
// Rate Limiter
$container->singleton(RateLimiter::class, function () use ($container) {
return new RateLimiter($container->resolve(Connection::class));
});
// Authenticator
$container->singleton(Authenticator::class, function () use ($container) {
return new Authenticator(
$container->resolve(Connection::class),
$container->resolve(PasswordHasher::class),
$container->resolve(SessionManager::class),
$container->resolve(RateLimiter::class)
);
});
// Permission Engine
$container->singleton(PermissionEngine::class, function () use ($container) {
$perms = $container->resolve(Config::class)->get('permissions');
return new PermissionEngine($perms, $container->resolve(Connection::class));
});
// Audit Logger
$container->singleton(AuditLogger::class, function () use ($container) {
$cfg = $container->resolve(Config::class)->get('database');
return new AuditLogger($cfg);
});
// Event Dispatcher
$container->singleton(EventDispatcher::class, fn() => new EventDispatcher());
// State Machine
$container->singleton(StateMachine::class, function () use ($container) {
return new StateMachine($container->resolve(AuditLogger::class));
});
// Notification Manager
$container->singleton(NotificationManager::class, function () use ($container) {
return new NotificationManager($container->resolve(Connection::class));
});
// Calculation Engine
$container->singleton(CalculationEngine::class, fn() => new CalculationEngine());
// Validator
$container->singleton(Validator::class, function () use ($container) {
return new Validator($container->resolve(Connection::class));
});
// File Manager
$container->singleton(FileManager::class, function () use ($container) {
return new FileManager(
ROOT_PATH . '/storage/uploads',
$container->resolve(Connection::class)
);
});
// Search Engine
$container->singleton(SearchEngine::class, function () use ($container) {
return new SearchEngine($container->resolve(Connection::class));
});
// Export Manager
$container->singleton(ExportManager::class, function () {
return new ExportManager(ROOT_PATH . '/storage/exports');
});
// Job Runner
$container->singleton(JobRunner::class, function () use ($container) {
return new JobRunner($container->resolve(Connection::class));
});
// Router
$container->singleton(Router::class, fn() => new Router());
// App
$container->singleton(App::class, function () use ($container) {
return new App($container);
});
// Register Event Listeners
$dispatcher = $container->resolve(EventDispatcher::class);
$listenerRegistrar = ROOT_PATH . '/config/event_listeners.php';
if (file_exists($listenerRegistrar)) {
$listeners = require $listenerRegistrar;
foreach ($listeners as $event => $handlers) {
foreach ($handlers as $handler) {
$dispatcher->listen($event, function ($payload) use ($handler, $container) {
$instance = is_string($handler) ? $container->resolve($handler) : $handler;
$instance->handle($payload);
});
}
}
}
// Register Calculators
$calcEngine = $container->resolve(CalculationEngine::class);
$calculators = ROOT_PATH . '/config/calculators.php';
if (file_exists($calculators)) {
$calcs = require $calculators;
foreach ($calcs as $name => $className) {
$calcEngine->register($name, $container->resolve($className));
}
}
// Register Scheduled Jobs
$jobRunner = $container->resolve(JobRunner::class);
$jobs = ROOT_PATH . '/config/scheduled_jobs.php';
if (file_exists($jobs)) {
$jobDefs = require $jobs;
foreach ($jobDefs as $key => $className) {
$jobRunner->register($key, $container->resolve($className));
}
}
// Load all module routes
$router = $container->resolve(Router::class);
$moduleDir = ROOT_PATH . '/modules';
$modules = array_filter(glob($moduleDir . '/*'), 'is_dir');
foreach ($modules as $modulePath) {
$routeFile = $modulePath . '/routes.php';
if (file_exists($routeFile)) {
require $routeFile;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
spl_autoload_register(function (string $class): void {
$prefixes = [
'Engine\\' => ROOT_PATH . '/engine/',
'Middleware\\' => ROOT_PATH . '/middleware/',
'Modules\\' => ROOT_PATH . '/modules/',
];
foreach ($prefixes as $prefix => $baseDir) {
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
continue;
}
$relativeClass = substr($class, $len);
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require $file;
return;
}
}
});
\ No newline at end of file
<?php
declare(strict_types=1);
define('ROOT_PATH', dirname(__DIR__));
require ROOT_PATH . '/bootstrap/autoload.php';
require ROOT_PATH . '/bootstrap/app.php';
use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Auth\PasswordHasher;
$db = Container::getInstance()->resolve(Connection::class);
$hasher = Container::getInstance()->resolve(PasswordHasher::class);
echo "=== CREATE SUPER ADMIN ===\n\n";
$username = $argv[1] ?? 'superadmin';
$password = $argv[2] ?? 'Admin@12345';
$existing = $db->fetchOne("SELECT id FROM users WHERE username = ?", [$username]);
if ($existing) {
echo "User '{$username}' already exists (ID: {$existing['id']}). Updating password.\n";
$db->update('users', ['password_hash' => $hasher->hash($password)], 'id = ?', [$existing['id']]);
echo "Password updated.\n";
exit(0);
}
$id = $db->insert('users', [
'username' => $username,
'password_hash' => $hasher->hash($password),
'role' => 'super_admin',
'full_name_en' => 'System Administrator',
'full_name_ar' => 'مدير النظام',
'national_id' => '00000000000000',
'date_of_birth' => '1990-01-01',
'phone_primary' => '01000000000',
'address' => 'AL-Arcade HQ',
'emergency_contact_name' => 'Emergency Contact',
'emergency_contact_phone' => '01000000001',
'emergency_contact_relationship' => 'other',
'bank_name' => 'N/A',
'bank_account_number' => '0000000000',
'bank_account_holder' => 'System Administrator',
'status' => 'active',
'activation_date' => date('Y-m-d'),
'is_active' => 1,
]);
echo "Super Admin created!\n";
echo " ID: {$id}\n";
echo " Username: {$username}\n";
echo " Password: {$password}\n";
echo "\nChange this password immediately after first login.\n";
\ No newline at end of file
<?php
declare(strict_types=1);
define('ROOT_PATH', dirname(__DIR__));
require ROOT_PATH . '/bootstrap/autoload.php';
require ROOT_PATH . '/bootstrap/app.php';
use Engine\Core\Container;
use Engine\Core\Config;
use Engine\Database\Connection;
$db = Container::getInstance()->resolve(Connection::class);
$config = Container::getInstance()->resolve(Config::class);
echo "=== SEEDING DATABASE ===\n\n";
// 1. System Settings
$settings = [
['key' => 'full_timer_in_office_day_rate', 'value' => '2400', 'value_type' => 'decimal', 'group' => 'salary'],
['key' => 'full_timer_remote_day_rate', 'value' => '1600', 'value_type' => 'decimal', 'group' => 'salary'],
['key' => 'intern_in_office_day_rate', 'value' => '1000', 'value_type' => 'decimal', 'group' => 'salary'],
['key' => 'intern_remote_day_rate', 'value' => '500', 'value_type' => 'decimal', 'group' => 'salary'],
['key' => 'report_deadline_time', 'value' => '23:59', 'value_type' => 'string', 'group' => 'reports'],
['key' => 'report_grace_hours', 'value' => '24', 'value_type' => 'integer', 'group' => 'reports'],
['key' => 'session_timeout_seconds', 'value' => '28800','value_type' => 'integer', 'group' => 'auth'],
['key' => 'max_login_attempts', 'value' => '5', 'value_type' => 'integer', 'group' => 'auth'],
['key' => 'lockout_duration_seconds', 'value' => '1800', 'value_type' => 'integer', 'group' => 'auth'],
['key' => 'deduction_response_hours', 'value' => '48', 'value_type' => 'integer', 'group' => 'deductions'],
['key' => 'pip_threshold_pct', 'value' => '40', 'value_type' => 'integer', 'group' => 'deductions'],
['key' => 'min_bounty_amount', 'value' => '50', 'value_type' => 'decimal', 'group' => 'bounties'],
['key' => 'admin_bounty_cap', 'value' => '5000', 'value_type' => 'decimal', 'group' => 'bounties'],
['key' => 'admin_monthly_bounty_budget', 'value' => '50000','value_type' => 'decimal', 'group' => 'bounties'],
['key' => 'auto_archive_done_days', 'value' => '30', 'value_type' => 'integer', 'group' => 'boards'],
['key' => 'payroll_calculation_day', 'value' => '25', 'value_type' => 'integer', 'group' => 'payroll'],
['key' => 'show_rank_on_hud', 'value' => '1', 'value_type' => 'boolean', 'group' => 'display'],
['key' => 'dark_mode_available', 'value' => '1', 'value_type' => 'boolean', 'group' => 'display'],
['key' => 'company_name', 'value' => 'AL-Arcade', 'value_type' => 'string', 'group' => 'general'],
['key' => 'max_schedule_changes_per_quarter', 'value' => '1', 'value_type' => 'integer', 'group' => 'schedules'],
['key' => 'schedule_change_notice_days', 'value' => '7', 'value_type' => 'integer', 'group' => 'schedules'],
['key' => 'auto_approval_enabled', 'value' => '1', 'value_type' => 'boolean', 'group' => 'reports'],
];
foreach ($settings as $s) {
$exists = $db->fetchOne("SELECT `key` FROM system_settings WHERE `key` = ?", [$s['key']]);
if (!$exists) {
$db->insert('system_settings', $s);
echo " ✅ Setting: {$s['key']}\n";
}
}
// 2. Default Labels
$labels = $config->all('default_labels');
$sa = $db->fetchOne("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1");
$saId = $sa ? $sa['id'] : 1;
foreach ($labels as $label) {
$exists = $db->fetchOne("SELECT id FROM labels WHERE text = ? AND scope = 'organization'", [$label['text']]);
if (!$exists) {
$db->insert('labels', [
'text' => $label['text'],
'bg_color' => $label['bg_color'],
'text_color' => $label['text_color'],
'scope' => 'organization',
'board_id' => null,
'created_by_id' => $saId,
]);
echo " ✅ Label: {$label['text']}\n";
}
}
// 3. Competency Areas
$areas = [
'Device maintenance, debugging, and OS troubleshooting',
'Collaborative work and source control (Git)',
'C# mastery: data structures, algorithms, OOP',
'Design patterns, architecture, parallel/concurrent programming',
'Legacy code: maintenance, debugging, upgrading',
'Unity Game Development and render pipelines',
'Deployment: PC, Android, Web',
'Unity Netcode for GameObjects + Unity multiplayer services',
'Industry-standard Unity assets (DOTween, Feel, TextMeshPro, etc.)',
'Unity + MySQL: basic CRUD',
'Unity + Firebase services',
];
foreach ($areas as $i => $area) {
$exists = $db->fetchOne("SELECT id FROM competency_areas WHERE name = ?", [$area]);
if (!$exists) {
$db->insert('competency_areas', [
'name' => $area,
'position' => $i + 1,
'is_active' => 1,
]);
echo " ✅ Competency: " . substr($area, 0, 50) . "...\n";
}
}
// 4. Background Jobs
$jobs = $config->all('scheduled_jobs');
foreach (array_keys($jobs) as $key) {
$exists = $db->fetchOne("SELECT id FROM background_jobs WHERE job_key = ?", [$key]);
if (!$exists) {
$db->insert('background_jobs', [
'job_key' => $key,
'is_enabled' => 1,
]);
echo " ✅ Job: {$key}\n";
}
}
echo "\n=== SEEDING COMPLETE ===\n";
\ No newline at end of file
<?php
return [
'name' => 'AL-ARCADE HR Platform',
'version' => '3.0.0',
'url' => $_ENV['APP_URL'] ?? 'http://localhost',
'debug' => (bool)($_ENV['APP_DEBUG'] ?? false),
'timezone' => 'Africa/Cairo',
'locale' => 'en',
'key' => $_ENV['APP_KEY'] ?? 'al-arcade-hr-v3-secret-key-change-me',
];
\ No newline at end of file
<?php
return [
'base_salary' => Modules\Salary\Calculators\BaseSalaryCalculator::class,
'daily_rate' => Modules\Salary\Calculators\DailyRateCalculator::class,
'expected_working_days' => Modules\Salary\Calculators\ExpectedWorkingDaysCalculator::class,
'live_salary' => Modules\Salary\Calculators\LiveSalaryCalculator::class,
];
\ No newline at end of file
<?php
return [
'host' => $_ENV['DB_HOST'] ?? 'srv-captain--mysql-db',
'port' => (int)($_ENV['DB_PORT'] ?? 3306),
'database' => $_ENV['DB_NAME'] ?? 'al_arcade_hr',
'username' => $_ENV['DB_USER'] ?? 'root',
'password' => $_ENV['DB_PASS'] ?? 'Alarcade123#',
'charset' => 'utf8mb4',
'collation'=> 'utf8mb4_unicode_ci',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_STRINGIFY_FETCHES => false,
],
];
\ No newline at end of file
<?php
return [
'A' => [
'name' => 'Deadline Violations',
'sub_categories' => [
'A1' => [
'name' => 'Slight Delay',
'condition' => '1-3 calendar days past deadline',
'formula' => 'daily_rate * 0.05 * days_late',
'auto' => true,
],
'A2' => [
'name' => 'Moderate Delay',
'condition' => '4-7 calendar days past deadline',
'formula' => 'daily_rate * 0.10 * days_late',
'auto' => true,
],
'A3' => [
'name' => 'Severe Delay',
'condition' => '8-14 calendar days past deadline',
'formula' => 'daily_rate * 0.15 * days_late',
'auto' => true,
],
'A4' => [
'name' => 'Critical Delay',
'condition' => '15+ calendar days past deadline or task abandoned',
'formula' => 'actual_salary * 0.25',
'auto' => true,
],
'A5' => [
'name' => 'Complete Failure',
'condition' => 'Deliverable completely unusable, must be redone',
'formula' => 'manual_up_to_50_percent_task',
'auto' => false,
],
],
],
'B' => [
'name' => 'Reporting Violations',
'sub_categories' => [
'B1' => [
'name' => 'Late Report',
'condition' => 'Report submitted after deadline but within grace period',
'formula' => 'warning_first_two_then_daily_rate_0.02',
'auto' => true,
],
'B2' => [
'name' => 'Unreported Day',
'condition' => 'No report and no unavailability logged',
'formula' => 'daily_rate',
'auto' => true,
],
'B3' => [
'name' => 'Vague/Useless Report',
'condition' => 'Flagged as vague 3+ times in a month',
'formula' => 'actual_salary * 0.05',
'auto' => true,
],
'B4' => [
'name' => 'Falsified Report',
'condition' => 'Report proven fabricated or intentionally misleading',
'formula' => 'actual_salary * 0.25',
'auto' => false,
],
],
],
'C' => [
'name' => 'Quality Violations',
'sub_categories' => [
'C1' => [
'name' => 'Minor Quality Issues',
'condition' => 'Work needs revision but fundamentally functions',
'formula' => 'daily_rate * 0.03',
'auto' => false,
],
'C2' => [
'name' => 'Significant Quality Issues',
'condition' => 'Major rework required',
'formula' => 'daily_rate * 0.10',
'auto' => false,
],
'C3' => [
'name' => 'Critical Quality Issues',
'condition' => 'Near-complete redo needed',
'formula' => 'daily_rate * 0.25',
'auto' => false,
],
'C4' => [
'name' => 'Regression',
'condition' => 'Broke existing working functionality',
'formula' => 'actual_salary * 0.15',
'auto' => false,
],
],
],
'D' => [
'name' => 'Communication Violations',
'sub_categories' => [
'D1' => [
'name' => 'Slow Response',
'condition' => 'No response within 24 hours on a working day',
'formula' => 'warning_first_then_daily_rate_0.02',
'auto' => false,
],
'D2' => [
'name' => 'No-Show Meeting',
'condition' => 'Missed meeting without 2hr notice',
'formula' => 'daily_rate * 0.05',
'auto' => false,
],
'D3' => [
'name' => 'Disappeared',
'condition' => 'Unresponsive 3+ consecutive working days',
'formula' => 'actual_salary * 0.15',
'auto' => false,
],
'D4' => [
'name' => 'Unprofessional Conduct',
'condition' => 'Hostile, abusive, or unprofessional behavior',
'formula' => 'actual_salary * 0.10_to_0.25',
'auto' => false,
],
],
],
];
\ No newline at end of file
<?php
return [
['name' => 'Backlog', 'slug' => 'backlog', 'icon' => '📋', 'position' => 1, 'is_system' => true],
['name' => 'Todo', 'slug' => 'todo', 'icon' => '📌', 'position' => 2, 'is_system' => true],
['name' => 'Doing', 'slug' => 'doing', 'icon' => '🔨', 'position' => 3, 'is_system' => true],
['name' => 'Frozen', 'slug' => 'frozen', 'icon' => '🧊', 'position' => 4, 'is_system' => true],
['name' => 'In Review', 'slug' => 'in_review', 'icon' => '🔍', 'position' => 5, 'is_system' => true],
['name' => 'Done', 'slug' => 'done', 'icon' => '✅', 'position' => 6, 'is_system' => true],
];
\ No newline at end of file
<?php
return [
['text' => 'Bug', 'bg_color' => '#EF4444', 'text_color' => '#FFFFFF'],
['text' => 'Feature', 'bg_color' => '#22C55E', 'text_color' => '#FFFFFF'],
['text' => 'Enhancement', 'bg_color' => '#3B82F6', 'text_color' => '#FFFFFF'],
['text' => 'UI/UX', 'bg_color' => '#EAB308', 'text_color' => '#000000'],
['text' => 'Backend', 'bg_color' => '#8B5CF6', 'text_color' => '#FFFFFF'],
['text' => 'Urgent', 'bg_color' => '#F97316', 'text_color' => '#FFFFFF'],
['text' => 'DevOps', 'bg_color' => '#1F2937', 'text_color' => '#FFFFFF'],
['text' => 'Design', 'bg_color' => '#EC4899', 'text_color' => '#FFFFFF'],
['text' => 'Research', 'bg_color' => '#06B6D4', 'text_color' => '#000000'],
['text' => 'Documentation', 'bg_color' => '#6B7280', 'text_color' => '#FFFFFF'],
];
\ No newline at end of file
<?php
return [
'technical' => [
'code_quality' => [
'name' => 'Code Quality & Architecture',
'weight' => 0.25,
'auto' => false,
],
'task_completion_rate' => [
'name' => 'Task Completion Rate',
'weight' => 0.25,
'auto' => true,
'formula' => '(cards_done / cards_assigned) * 5',
],
'deadline_compliance' => [
'name' => 'Deadline Compliance',
'weight' => 0.20,
'auto' => true,
'formula' => '(cards_on_time / cards_with_deadline) * 5',
],
'technical_growth' => [
'name' => 'Technical Growth',
'weight' => 0.15,
'auto' => false,
],
'problem_solving' => [
'name' => 'Problem-Solving & Initiative',
'weight' => 0.15,
'auto' => false,
],
],
'professional' => [
'reporting_compliance' => [
'name' => 'Reporting Compliance',
'weight' => 0.25,
'auto' => true,
'formula' => '(reports_on_time / expected_reports) * 5',
],
'communication_quality' => [
'name' => 'Communication Quality',
'weight' => 0.25,
'auto' => false,
],
'collaboration' => [
'name' => 'Collaboration',
'weight' => 0.20,
'auto' => false,
],
'reliability' => [
'name' => 'Reliability & Consistency',
'weight' => 0.20,
'auto' => false,
],
'policy_compliance' => [
'name' => 'Policy Compliance',
'weight' => 0.10,
'auto' => true,
'formula' => 'max(1, 5 - (violations * 0.5))',
],
],
'overall_weights' => [
'technical' => 0.5,
'professional' => 0.5,
],
'ratings' => [
['min' => 4.5, 'max' => 5.0, 'rating' => 'exceptional', 'label' => '⭐ Exceptional'],
['min' => 3.5, 'max' => 4.49,'rating' => 'strong', 'label' => '🟢 Strong'],
['min' => 2.5, 'max' => 3.49,'rating' => 'adequate', 'label' => '🟡 Adequate'],
['min' => 1.5, 'max' => 2.49,'rating' => 'below_expectations', 'label' => '🟠 Below Expectations'],
['min' => 1.0, 'max' => 1.49,'rating' => 'unacceptable', 'label' => '🔴 Unacceptable'],
],
];
\ No newline at end of file
<?php
return [
// Phase 1 listeners - modules register their own via this config
// Format: 'event.name' => [ListenerClass::class, ...]
];
\ No newline at end of file
<?php
return [
'color_zones' => [
['min_pct' => 100, 'max_pct' => 999, 'color' => 'gold', 'class' => 'hud-exceptional'],
['min_pct' => 80, 'max_pct' => 100, 'color' => 'green', 'class' => 'hud-healthy'],
['min_pct' => 60, 'max_pct' => 80, 'color' => 'yellow', 'class' => 'hud-warning'],
['min_pct' => 0, 'max_pct' => 60, 'color' => 'red', 'class' => 'hud-critical'],
],
'health_indicator' => [
'healthy' => ['max_deductions' => 1, 'min_retention' => 0.80, 'icon' => '🟢', 'label' => 'Healthy'],
'warning' => ['max_deductions' => 3, 'min_retention' => 0.60, 'icon' => '🟡', 'label' => 'Warning'],
'critical' => ['max_deductions' => 999, 'min_retention' => 0.00, 'icon' => '🔴', 'label' => 'Critical'],
],
'pip_threshold_pct' => 40,
];
\ No newline at end of file
<?php
return [
'deduction.issued' => [
'tier' => 'blocking',
'title' => 'Deduction Issued',
'body' => 'A deduction of {amount} EGP ({category}{sub_category}) has been issued against you. Reason: {description}',
],
'bounty.earned' => [
'tier' => 'important',
'title' => '💰 Bounty Earned!',
'body' => 'You earned {amount} EGP for completing {card_key}: {card_title}',
],
'card.assigned' => [
'tier' => 'important',
'title' => 'New Task Assigned',
'body' => "You've been assigned to {card_key}: {card_title} on {board_name}",
],
'card.deadline_approaching' => [
'tier' => 'important',
'title' => '⏰ Deadline Approaching',
'body' => '{card_key}: {card_title} is due {when}',
],
'report.flagged' => [
'tier' => 'important',
'title' => 'Report Flagged',
'body' => 'Your report for {date} was flagged: {reason}',
],
'evaluation.compiled' => [
'tier' => 'blocking',
'title' => 'Monthly Evaluation Published',
'body' => 'Your evaluation for {month} has been compiled. Overall score: {score}',
],
'pip.created' => [
'tier' => 'blocking',
'title' => 'Performance Improvement Plan Issued',
'body' => 'A PIP has been issued for you. Duration: {duration} days. Please review the details.',
],
'payroll.approved' => [
'tier' => 'important',
'title' => 'Payment Approved',
'body' => 'Your payment for {month} has been approved: {amount} EGP',
],
'salary.changed' => [
'tier' => 'important',
'title' => 'Salary Updated',
'body' => 'Your salary has been updated to {amount} EGP',
],
'welcome' => [
'tier' => 'important',
'title' => 'Welcome to The Grind!',
'body' => 'Welcome to The Grind, {name}! Your account is now active.',
],
'message.received' => [
'tier' => 'important',
'title' => 'New Message',
'body' => '{sender} sent you a message',
],
];
\ No newline at end of file
<?php
return [
'profile_photo' => ['name' => 'Profile photo uploaded', 'auto' => true, 'verifier' => 'system'],
'bank_details' => ['name' => 'Bank details provided', 'auto' => true, 'verifier' => 'system'],
'contract_signed' => ['name' => 'Contract signed', 'auto' => true, 'verifier' => 'system'],
'policies_ack' => ['name' => 'All policies acknowledged', 'auto' => true, 'verifier' => 'system'],
'competency_done' => ['name' => 'Competency self-assessment completed', 'auto' => true, 'verifier' => 'system'],
'device_setup' => ['name' => 'Device setup confirmed', 'auto' => false, 'verifier' => 'contractor'],
'source_control' => ['name' => 'Source control access configured', 'auto' => false, 'verifier' => 'project_leader'],
'board_assigned' => ['name' => 'First board assigned', 'auto' => true, 'verifier' => 'system'],
'intro_meeting' => ['name' => 'Introduction meeting completed', 'auto' => false, 'verifier' => 'admin'],
];
\ No newline at end of file
<?php
return [
// User Management
'users.create' => ['super_admin', 'admin'],
'users.edit.any' => ['super_admin'],
'users.edit.limited' => ['admin'],
'users.edit.own' => ['contractor', 'project_leader', 'admin', 'super_admin'],
'users.deactivate' => ['super_admin'],
'users.terminate' => ['super_admin'],
'users.reset_password' => ['super_admin'],
'users.force_logout' => ['super_admin'],
'users.change_role' => ['super_admin'],
'users.set_salary' => ['super_admin'],
'users.view_salary.any' => ['super_admin', 'admin'],
'users.view_salary.own' => ['contractor', 'project_leader', 'admin', 'super_admin'],
'users.view_directory' => ['super_admin', 'admin', 'project_leader', 'contractor'],
'users.view_private_notes' => ['super_admin', 'admin'],
'users.create_private_notes' => ['super_admin', 'admin'],
// Board Management
'boards.create' => ['super_admin', 'admin'],
'boards.edit' => ['super_admin', 'admin'],
'boards.archive' => ['super_admin'],
'boards.delete' => ['super_admin'],
'boards.restore' => ['super_admin'],
'boards.manage_members' => ['super_admin', 'admin'],
'boards.create_from_template'=> ['super_admin', 'admin'],
'boards.save_as_template' => ['super_admin', 'admin'],
// Column Management
'columns.create' => ['super_admin', 'admin'],
'columns.edit' => ['super_admin', 'admin'],
'columns.delete' => ['super_admin', 'admin'],
// Card Management
'cards.create.any' => ['super_admin', 'admin'],
'cards.create.own_boards' => ['project_leader'],
'cards.create.backlog' => ['contractor'],
'cards.edit.any' => ['super_admin', 'admin'],
'cards.edit.own_boards' => ['project_leader'],
'cards.edit.own_cards' => ['contractor'],
'cards.delete' => ['super_admin', 'admin', 'project_leader'],
'cards.archive' => ['super_admin', 'admin', 'project_leader'],
'cards.restore' => ['super_admin', 'admin'],
'cards.duplicate' => ['super_admin', 'admin', 'project_leader'],
'cards.assign' => ['super_admin', 'admin', 'project_leader'],
'cards.move_to_done' => ['super_admin', 'admin', 'project_leader'],
'cards.move.own_cards' => ['contractor'],
'cards.move.any' => ['super_admin', 'admin', 'project_leader'],
'cards.set_bounty' => ['super_admin', 'admin'],
'cards.set_deadline' => ['super_admin', 'admin', 'project_leader'],
'cards.watch' => ['super_admin', 'admin', 'project_leader', 'contractor'],
'cards.comment' => ['super_admin', 'admin', 'project_leader', 'contractor'],
'cards.attach' => ['super_admin', 'admin', 'project_leader', 'contractor'],
// Labels
'labels.create.org' => ['super_admin'],
'labels.edit.org' => ['super_admin'],
'labels.delete.org' => ['super_admin'],
'labels.create.board' => ['super_admin', 'admin', 'project_leader'],
'labels.edit.board' => ['super_admin', 'admin', 'project_leader'],
'labels.delete.board' => ['super_admin', 'admin', 'project_leader'],
'labels.apply' => ['super_admin', 'admin', 'project_leader', 'contractor'],
// Templates
'card_templates.manage' => ['super_admin', 'admin', 'project_leader'],
'board_templates.manage' => ['super_admin', 'admin'],
'deduction_presets.manage' => ['super_admin', 'admin'],
// Reports
'reports.submit' => ['super_admin', 'admin', 'project_leader', 'contractor'],
'reports.view.any' => ['super_admin', 'admin'],
'reports.view.own_team' => ['project_leader'],
'reports.view.own' => ['contractor'],
'reports.review' => ['super_admin', 'admin', 'project_leader'],
'reports.bulk_approve' => ['super_admin', 'admin', 'project_leader'],
'reports.edit' => ['super_admin'],
'reports.delete' => ['super_admin'],
// Deductions
'deductions.initiate' => ['super_admin', 'admin', 'project_leader'],
'deductions.review' => ['super_admin', 'admin'],
'deductions.edit' => ['super_admin'],
'deductions.delete' => ['super_admin'],
'deductions.view.any' => ['super_admin', 'admin'],
'deductions.view.own_team' => ['project_leader'],
'deductions.view.own' => ['contractor'],
// Bounties
'bounties.set' => ['super_admin', 'admin'],
'bounties.edit' => ['super_admin', 'admin'],
'bounties.revoke' => ['super_admin'],
'bounties.view.any' => ['super_admin', 'admin'],
// Salary & Payroll
'salary.set' => ['super_admin'],
'adjustments.create' => ['super_admin', 'admin'],
'adjustments.approve' => ['super_admin'],
'adjustments.view.any' => ['super_admin', 'admin'],
'payroll.calculate' => ['super_admin', 'admin'],
'payroll.review' => ['super_admin', 'admin'],
'payroll.approve' => ['super_admin'],
'payroll.edit' => ['super_admin'],
'payroll.delete' => ['super_admin'],
'payroll.view.any' => ['super_admin', 'admin'],
'payroll.view.own' => ['contractor'],
// Evaluations
'evaluations.submit_tech' => ['super_admin', 'project_leader'],
'evaluations.submit_prof' => ['super_admin', 'admin'],
'evaluations.edit' => ['super_admin'],
'evaluations.delete' => ['super_admin'],
'evaluations.view.any' => ['super_admin', 'admin'],
'evaluations.view.own_team' => ['project_leader'],
'evaluations.view.own' => ['contractor'],
// PIPs
'pips.create' => ['super_admin', 'admin'],
'pips.edit' => ['super_admin', 'admin'],
'pips.close' => ['super_admin', 'admin'],
'pips.delete' => ['super_admin'],
'pips.log_checkin' => ['super_admin', 'admin', 'project_leader'],
// Learning Goals
'learning_goals.create' => ['super_admin', 'admin', 'project_leader'],
'learning_goals.edit' => ['super_admin', 'admin', 'project_leader'],
'learning_goals.assess' => ['super_admin', 'admin', 'project_leader'],
'learning_goals.delete' => ['super_admin'],
// Messaging
'messages.send' => ['super_admin', 'admin', 'project_leader', 'contractor'],
'messages.delete' => ['super_admin'],
'messages.view_any' => ['super_admin'],
// Notifications & Notices
'notices.create' => ['super_admin', 'admin'],
'notices.edit' => ['super_admin', 'admin'],
'notices.delete' => ['super_admin'],
// Meetings
'meetings.create' => ['super_admin', 'admin', 'project_leader'],
'meetings.edit' => ['super_admin', 'admin', 'project_leader'],
'meetings.delete' => ['super_admin'],
// Holidays
'holidays.manage' => ['super_admin', 'admin'],
// Contracts & Policies
'policies.manage' => ['super_admin'],
'contracts.view.any' => ['super_admin', 'admin'],
'contracts.view.own' => ['contractor'],
'contracts.edit' => ['super_admin'],
// System
'settings.manage' => ['super_admin'],
'audit_trail.view.full' => ['super_admin'],
'audit_trail.view.limited' => ['admin'],
'api_keys.manage' => ['super_admin'],
'webhooks.manage' => ['super_admin'],
'system_health.view' => ['super_admin'],
'data.export' => ['super_admin', 'admin', 'project_leader'],
// Search
'search.global' => ['super_admin', 'admin', 'project_leader', 'contractor'],
// Invites
'invites.create' => ['super_admin', 'admin'],
'invites.manage' => ['super_admin', 'admin'],
'invites.delete' => ['super_admin'],
// Schedules
'schedules.edit_direct' => ['super_admin'],
'schedules.approve_request' => ['super_admin', 'admin'],
// Unavailability
'unavailability.manage.own' => ['contractor', 'project_leader', 'admin', 'super_admin'],
'unavailability.manage.any' => ['super_admin', 'admin'],
// Control Panel
'control_panel.access' => ['super_admin'],
];
\ No newline at end of file
<?php
return [
'detect_unreported_days' => Modules\Reports\Jobs\DetectUnreportedDaysJob::class,
'auto_archive_done_cards' => Modules\Cards\Jobs\AutoArchiveDoneCardsJob::class,
'send_deadline_reminders' => Modules\Cards\Jobs\SendDeadlineRemindersJob::class,
'invite_expiry' => Modules\Onboarding\Jobs\InviteExpiryJob::class,
'session_cleanup' => Modules\Auth\Jobs\SessionCleanupJob::class,
'auto_apply_deductions' => Modules\Deductions\Jobs\AutoApplyExpiredDeductionsJob::class,
];
\ No newline at end of file
<?php
return [
'card.created',
'card.moved',
'card.assigned',
'card.done',
'card.overdue',
'report.submitted',
'report.missed',
'deduction.created',
'deduction.applied',
'bounty.paid',
'contractor.activated',
'contractor.terminated',
'payroll.approved',
'evaluation.compiled',
];
\ No newline at end of file
<?php
declare(strict_types=1);
define('ROOT_PATH', dirname(__DIR__));
require ROOT_PATH . '/bootstrap/autoload.php';
require ROOT_PATH . '/bootstrap/app.php';
use Engine\Core\Container;
use Engine\Scheduler\JobRunner;
$runner = Container::getInstance()->resolve(JobRunner::class);
$results = $runner->runDue();
foreach ($results as $job => $status) {
echo "[" . date('Y-m-d H:i:s') . "] {$job}: {$status}\n";
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Audit;
use PDO;
final class AuditLogger
{
private ?PDO $pdo = null;
private array $config;
private array $sensitiveFields = ['password', 'password_hash', 'temp_password_hash', 'key_hash', 'secret'];
public function __construct(array $config)
{
$this->config = $config;
}
private function pdo(): PDO
{
if ($this->pdo === null) {
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=%s',
$this->config['host'], $this->config['port'], $this->config['database'], $this->config['charset']);
$this->pdo = new PDO($dsn, $this->config['username'], $this->config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
}
return $this->pdo;
}
public function log(
?array $user,
string $action,
string $entityType,
?int $entityId,
string $module,
?string $endpoint = null,
?array $before = null,
?array $after = null,
?string $ip = null,
?string $userAgent = null
): void {
try {
$stmt = $this->pdo()->prepare(
"INSERT INTO audit_trail (user_id, username, user_role, action, entity_type, entity_id, module, endpoint, before_json, after_json, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
$stmt->execute([
$user['id'] ?? null,
$user['username'] ?? null,
$user['role'] ?? null,
$action,
$entityType,
$entityId,
$module,
$endpoint,
$before ? json_encode($this->sanitize($before)) : null,
$after ? json_encode($this->sanitize($after)) : null,
$ip,
$userAgent,
]);
} catch (\Throwable $e) {
error_log("[AuditLogger CRITICAL] Failed to log audit: {$e->getMessage()}");
}
}
private function sanitize(array $data): array
{
foreach ($this->sensitiveFields as $field) {
if (array_key_exists($field, $data)) {
$data[$field] = '***REDACTED***';
}
}
return $data;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Auth;
use Engine\Database\Connection;
final class Authenticator
{
private Connection $db;
private PasswordHasher $hasher;
private SessionManager $sessions;
private RateLimiter $rateLimiter;
public function __construct(Connection $db, PasswordHasher $hasher, SessionManager $sessions, RateLimiter $rateLimiter)
{
$this->db = $db;
$this->hasher = $hasher;
$this->sessions = $sessions;
$this->rateLimiter = $rateLimiter;
}
public function attempt(string $username, string $password, string $ip, string $userAgent): array
{
// Check rate limit
if ($this->rateLimiter->isLocked($username, $ip)) {
$this->logAttempt($username, $ip, $userAgent, false, 'locked');
return ['success' => false, 'error' => 'Account temporarily locked. Try again later.'];
}
$user = $this->db->fetchOne('SELECT * FROM users WHERE username = ?', [$username]);
if (!$user) {
$this->logAttempt($username, $ip, $userAgent, false, 'user_not_found');
$this->rateLimiter->recordFailure($username, $ip);
return ['success' => false, 'error' => 'Invalid username or password.'];
}
if (!$user['is_active']) {
$this->logAttempt($username, $ip, $userAgent, false, 'inactive');
return ['success' => false, 'error' => 'Account is deactivated.'];
}
if ($user['status'] === 'terminated') {
$this->logAttempt($username, $ip, $userAgent, false, 'terminated');
return ['success' => false, 'error' => 'Account has been terminated.'];
}
// Check temp password
$authenticated = false;
$usedTempPassword = false;
if ($user['temp_password_hash'] && $user['temp_password_expires_at'] > date('Y-m-d H:i:s')) {
if ($this->hasher->verify($password, $user['temp_password_hash'])) {
$authenticated = true;
$usedTempPassword = true;
}
}
if (!$authenticated && $this->hasher->verify($password, $user['password_hash'])) {
$authenticated = true;
}
if (!$authenticated) {
$this->logAttempt($username, $ip, $userAgent, false, 'wrong_password');
$this->rateLimiter->recordFailure($username, $ip);
return ['success' => false, 'error' => 'Invalid username or password.'];
}
// Success
$this->logAttempt($username, $ip, $userAgent, true, null);
$this->rateLimiter->clearFailures($username, $ip);
$sessionToken = $this->sessions->create($user['id'], $ip, $userAgent);
// Update last login
$this->db->update('users', ['last_login_at' => date('Y-m-d H:i:s')], 'id = ?', [$user['id']]);
// If used temp password, clear it and force change
if ($usedTempPassword) {
$this->db->update('users', [
'temp_password_hash' => null,
'temp_password_expires_at' => null,
'force_password_change' => 1,
], 'id = ?', [$user['id']]);
}
return [
'success' => true,
'user_id' => $user['id'],
'session_token' => $sessionToken,
'force_password_change' => (bool)$user['force_password_change'] || $usedTempPassword,
'role' => $user['role'],
'status' => $user['status'],
];
}
private function logAttempt(string $username, string $ip, string $userAgent, bool $success, ?string $reason): void
{
$this->db->insert('login_attempts', [
'username' => $username,
'ip_address' => $ip,
'user_agent' => $userAgent,
'success' => $success ? 1 : 0,
'failure_reason' => $reason,
]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Auth;
final class PasswordHasher
{
private int $cost;
public function __construct(int $cost = 12)
{
$this->cost = $cost;
}
public function hash(string $password): string
{
return password_hash($password, PASSWORD_BCRYPT, ['cost' => $this->cost]);
}
public function verify(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
public function needsRehash(string $hash): bool
{
return password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => $this->cost]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Auth;
use Engine\Database\Connection;
final class PermissionEngine
{
private array $permissions;
private Connection $db;
public function __construct(array $permissions, Connection $db)
{
$this->permissions = $permissions;
$this->db = $db;
}
public function can(array $user, string $action, array $context = []): bool
{
$role = $user['role'];
// Super admin can do everything
if ($role === 'super_admin') {
return true;
}
if (!isset($this->permissions[$action])) {
return false;
}
$allowedRoles = $this->permissions[$action];
if (!in_array($role, $allowedRoles, true)) {
return false;
}
// Scope-based checks
return $this->checkScope($user, $action, $context);
}
private function checkScope(array $user, string $action, array $context): bool
{
$role = $user['role'];
// Project leader board scope
if ($role === 'project_leader' && isset($context['board_id'])) {
if (str_contains($action, 'own_boards') || str_contains($action, '.board')) {
return $this->isUserOnBoard($user['id'], (int)$context['board_id'], 'project_leader');
}
}
// Contractor own-card scope
if ($role === 'contractor' && isset($context['card_id'])) {
if (str_contains($action, 'own_cards') || str_contains($action, '.own')) {
return $this->isAssignedToCard($user['id'], (int)$context['card_id']);
}
}
// PL team scope for reports/evaluations
if ($role === 'project_leader' && isset($context['target_user_id'])) {
if (str_contains($action, 'own_team')) {
return $this->isOnSameBoard($user['id'], (int)$context['target_user_id']);
}
}
return true;
}
private function isUserOnBoard(int $userId, int $boardId, ?string $roleOnBoard = null): bool
{
$sql = "SELECT 1 FROM board_members WHERE user_id = ? AND board_id = ?";
$params = [$userId, $boardId];
if ($roleOnBoard) {
$sql .= " AND role_on_board = ?";
$params[] = $roleOnBoard;
}
return (bool)$this->db->fetchOne($sql, $params);
}
private function isAssignedToCard(int $userId, int $cardId): bool
{
return (bool)$this->db->fetchOne(
"SELECT 1 FROM card_assignments WHERE user_id = ? AND card_id = ?",
[$userId, $cardId]
);
}
private function isOnSameBoard(int $userId, int $targetUserId): bool
{
return (bool)$this->db->fetchOne(
"SELECT 1 FROM board_members bm1
JOIN board_members bm2 ON bm1.board_id = bm2.board_id
WHERE bm1.user_id = ? AND bm2.user_id = ?",
[$userId, $targetUserId]
);
}
public function denyUnlessAllowed(array $user, string $action, array $context = []): void
{
if (!$this->can($user, $action, $context)) {
throw new \RuntimeException("Permission denied: {$action}", 403);
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Auth;
use Engine\Database\Connection;
final class RateLimiter
{
private Connection $db;
private int $maxAttempts = 5;
private int $lockoutSeconds = 1800; // 30 minutes
private int $maxDailyAttempts = 15;
public function __construct(Connection $db)
{
$this->db = $db;
}
public function isLocked(string $username, string $ip): bool
{
// Check recent failures for username
$recentFailures = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM login_attempts
WHERE username = ? AND success = 0 AND created_at > DATE_SUB(NOW(), INTERVAL ? SECOND)",
[$username, $this->lockoutSeconds]
);
if ($recentFailures >= $this->maxAttempts) {
return true;
}
// Check daily failures
$dailyFailures = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM login_attempts
WHERE (username = ? OR ip_address = ?) AND success = 0 AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)",
[$username, $ip]
);
return $dailyFailures >= $this->maxDailyAttempts;
}
public function recordFailure(string $username, string $ip): void
{
// Already logged in Authenticator
}
public function clearFailures(string $username, string $ip): void
{
// No need to delete - the window-based check handles it
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Auth;
use Engine\Database\Connection;
final class SessionManager
{
private Connection $db;
private string $cookieName = 'al_arcade_session';
private int $lifetime = 28800; // 8 hours
public function __construct(Connection $db)
{
$this->db = $db;
}
public function create(int $userId, string $ip, string $userAgent): string
{
$token = bin2hex(random_bytes(64));
$this->db->insert('sessions', [
'id' => $token,
'user_id' => $userId,
'ip_address' => $ip,
'user_agent' => $userAgent,
'last_activity_at' => date('Y-m-d H:i:s'),
]);
setcookie($this->cookieName, $token, [
'expires' => time() + $this->lifetime,
'path' => '/',
'httponly' => true,
'secure' => isset($_SERVER['HTTPS']),
'samesite' => 'Lax',
]);
return $token;
}
public function validate(): ?array
{
$token = $_COOKIE[$this->cookieName] ?? null;
if (!$token) return null;
$session = $this->db->fetchOne(
"SELECT s.*, u.id as uid, u.username, u.role, u.full_name_en, u.status, u.is_active,
u.contractor_type, u.assigned_pl_id, u.actual_salary, u.base_salary,
u.force_password_change, u.theme_preference, u.profile_photo_id
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.last_activity_at > DATE_SUB(NOW(), INTERVAL ? SECOND)",
[$token, $this->lifetime]
);
if (!$session) {
$this->destroyToken($token);
return null;
}
if (!$session['is_active']) {
$this->destroyToken($token);
return null;
}
// Touch activity
$this->db->update('sessions', ['last_activity_at' => date('Y-m-d H:i:s')], 'id = ?', [$token]);
return [
'session_id' => $session['id'],
'id' => $session['uid'],
'username' => $session['username'],
'role' => $session['role'],
'full_name_en' => $session['full_name_en'],
'status' => $session['status'],
'is_active' => $session['is_active'],
'contractor_type' => $session['contractor_type'],
'assigned_pl_id' => $session['assigned_pl_id'],
'actual_salary' => $session['actual_salary'],
'base_salary' => $session['base_salary'],
'force_password_change'=> $session['force_password_change'],
'theme_preference' => $session['theme_preference'],
'profile_photo_id' => $session['profile_photo_id'],
];
}
public function destroy(): void
{
$token = $_COOKIE[$this->cookieName] ?? null;
if ($token) {
$this->destroyToken($token);
}
}
private function destroyToken(string $token): void
{
$this->db->delete('sessions', 'id = ?', [$token]);
setcookie($this->cookieName, '', ['expires' => time() - 3600, 'path' => '/']);
}
public function destroyAllForUser(int $userId, ?string $except = null): void
{
if ($except) {
$this->db->query('DELETE FROM sessions WHERE user_id = ? AND id != ?', [$userId, $except]);
} else {
$this->db->delete('sessions', 'user_id = ?', [$userId]);
}
}
public function listForUser(int $userId): array
{
return $this->db->fetchAll(
'SELECT id, ip_address, user_agent, last_activity_at, created_at FROM sessions WHERE user_id = ? ORDER BY last_activity_at DESC',
[$userId]
);
}
public function cleanup(): int
{
return $this->db->delete('sessions', 'last_activity_at < DATE_SUB(NOW(), INTERVAL ? SECOND)', [$this->lifetime]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Calculation;
final class CalculationEngine
{
private array $calculators = [];
public function register(string $name, CalculatorInterface $calculator): void
{
$this->calculators[$name] = $calculator;
}
public function calculate(string $name, array $context): mixed
{
if (!isset($this->calculators[$name])) {
throw new \RuntimeException("Calculator not registered: {$name}");
}
return $this->calculators[$name]->calculate($context);
}
public function has(string $name): bool
{
return isset($this->calculators[$name]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Calculation;
interface CalculatorInterface
{
public function calculate(array $context): mixed;
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Core;
use Engine\Auth\SessionManager;
use Engine\Audit\AuditLogger;
final class App
{
private static ?self $instance = null;
private Container $container;
public function __construct(Container $container)
{
$this->container = $container;
self::$instance = $this;
date_default_timezone_set(
$this->container->resolve(Config::class)->get('app.timezone', 'Africa/Cairo')
);
}
public static function getInstance(): self
{
return self::$instance;
}
public function container(): Container
{
return $this->container;
}
public function run(): void
{
$request = Request::capture();
$router = $this->container->resolve(Router::class);
try {
$route = $router->match($request);
if ($route === null) {
$this->sendError(404, 'Not Found');
return;
}
$middlewareClasses = $route['middleware'] ?? [];
$pipeline = new MiddlewarePipeline($this->container);
$response = $pipeline->send($request)
->through($middlewareClasses)
->then(function (Request $req) use ($route) {
return $this->executeRoute($route, $req);
});
$response->send();
} catch (\Throwable $e) {
$debug = $this->container->resolve(Config::class)->get('app.debug', false);
error_log("[App Error] {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}");
if ($debug) {
$this->sendError(500, $e->getMessage() . "\n" . $e->getTraceAsString());
} else {
$this->sendError(500, 'Internal Server Error');
}
}
}
private function executeRoute(array $route, Request $request): Response
{
$controllerClass = $route['controller'];
$method = $route['method'];
$params = $route['params'] ?? [];
$controller = $this->container->resolve($controllerClass);
if (!method_exists($controller, $method)) {
return Response::json(['error' => 'Method not found'], 404);
}
$result = $controller->$method($request, ...$params);
if ($result instanceof Response) {
return $result;
}
return Response::json($result);
}
private function sendError(int $code, string $message): void
{
http_response_code($code);
if (str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json')) {
header('Content-Type: application/json');
echo json_encode(['error' => $message, 'code' => $code]);
} else {
$templateEngine = $this->container->resolve(\Engine\Template\TemplateEngine::class);
$templateFile = ROOT_PATH . "/templates/errors/{$code}.php";
if (file_exists($templateFile)) {
echo $templateEngine->render("errors/{$code}", ['message' => $message]);
} else {
echo "<h1>{$code}</h1><p>" . htmlspecialchars($message) . "</p>";
}
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Core;
final class Config
{
private array $data = [];
private string $configPath;
public function __construct(string $configPath)
{
$this->configPath = $configPath;
}
public function get(string $key, mixed $default = null): mixed
{
$parts = explode('.', $key);
$file = array_shift($parts);
if (!isset($this->data[$file])) {
$path = $this->configPath . '/' . $file . '.php';
if (file_exists($path)) {
$this->data[$file] = require $path;
} else {
return $default;
}
}
$value = $this->data[$file];
foreach ($parts as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) {
return $default;
}
$value = $value[$part];
}
return $value;
}
public function all(string $file): array
{
if (!isset($this->data[$file])) {
$path = $this->configPath . '/' . $file . '.php';
if (file_exists($path)) {
$this->data[$file] = require $path;
} else {
return [];
}
}
return $this->data[$file];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Core;
final class Container
{
private static ?self $instance = null;
private array $bindings = [];
private array $singletons = [];
private array $resolved = [];
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function bind(string $abstract, callable $factory): void
{
$this->bindings[$abstract] = ['factory' => $factory, 'singleton' => false];
}
public function singleton(string $abstract, callable $factory): void
{
$this->bindings[$abstract] = ['factory' => $factory, 'singleton' => true];
}
public function resolve(string $abstract): mixed
{
if (isset($this->resolved[$abstract])) {
return $this->resolved[$abstract];
}
if (!isset($this->bindings[$abstract])) {
if (class_exists($abstract)) {
return new $abstract();
}
throw new \RuntimeException("No binding found for: {$abstract}");
}
$binding = $this->bindings[$abstract];
$instance = ($binding['factory'])();
if ($binding['singleton']) {
$this->resolved[$abstract] = $instance;
}
return $instance;
}
public function has(string $abstract): bool
{
return isset($this->bindings[$abstract]) || isset($this->resolved[$abstract]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Core;
final class MiddlewarePipeline
{
private Container $container;
private Request $request;
private array $middleware = [];
public function __construct(Container $container)
{
$this->container = $container;
}
public function send(Request $request): self
{
$this->request = $request;
return $this;
}
public function through(array $middleware): self
{
$this->middleware = $middleware;
return $this;
}
public function then(callable $destination): Response
{
$pipeline = array_reduce(
array_reverse($this->middleware),
function (callable $next, string $middlewareClass) {
return function (Request $request) use ($middlewareClass, $next): Response {
$middleware = $this->container->resolve($middlewareClass);
return $middleware->handle($request, $next);
};
},
function (Request $request) use ($destination): Response {
return $destination($request);
}
);
return $pipeline($this->request);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Core;
final class Request
{
private string $method;
private string $uri;
private array $query;
private array $body;
private array $files;
private array $server;
private array $cookies;
private array $headers;
private array $attributes = [];
private function __construct()
{
$this->method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
$this->uri = $this->parseUri();
$this->query = $_GET;
$this->body = $this->parseBody();
$this->files = $_FILES;
$this->server = $_SERVER;
$this->cookies = $_COOKIE;
$this->headers = $this->parseHeaders();
}
public static function capture(): self
{
return new self();
}
private function parseUri(): string
{
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$uri = strtok($uri, '?');
$uri = '/' . trim($uri, '/');
return $uri === '/' ? '/' : rtrim($uri, '/');
}
private function parseBody(): array
{
if ($this->method === 'GET') {
return [];
}
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (str_contains($contentType, 'application/json')) {
$raw = file_get_contents('php://input');
return json_decode($raw, true) ?? [];
}
return $_POST;
}
private function parseHeaders(): array
{
$headers = [];
foreach ($_SERVER as $key => $value) {
if (str_starts_with($key, 'HTTP_')) {
$name = str_replace('_', '-', strtolower(substr($key, 5)));
$headers[$name] = $value;
}
}
return $headers;
}
public function method(): string { return $this->method; }
public function uri(): string { return $this->uri; }
public function isJson(): bool { return str_contains($this->header('content-type', ''), 'json'); }
public function isAjax(): bool { return $this->header('x-requested-with') === 'XMLHttpRequest'; }
public function wantsJson(): bool { return str_contains($this->header('accept', ''), 'json') || $this->isJson(); }
public function query(string $key, mixed $default = null): mixed
{
return $this->query[$key] ?? $default;
}
public function input(string $key, mixed $default = null): mixed
{
return $this->body[$key] ?? $this->query[$key] ?? $default;
}
public function all(): array
{
return array_merge($this->query, $this->body);
}
public function only(array $keys): array
{
return array_intersect_key($this->all(), array_flip($keys));
}
public function file(string $key): ?array
{
return $this->files[$key] ?? null;
}
public function header(string $key, mixed $default = null): mixed
{
return $this->headers[strtolower($key)] ?? $default;
}
public function cookie(string $key, mixed $default = null): mixed
{
return $this->cookies[$key] ?? $default;
}
public function ip(): string
{
return $this->server['HTTP_X_FORWARDED_FOR']
?? $this->server['HTTP_X_REAL_IP']
?? $this->server['REMOTE_ADDR']
?? '0.0.0.0';
}
public function userAgent(): string
{
return $this->server['HTTP_USER_AGENT'] ?? '';
}
public function setAttribute(string $key, mixed $value): void
{
$this->attributes[$key] = $value;
}
public function getAttribute(string $key, mixed $default = null): mixed
{
return $this->attributes[$key] ?? $default;
}
public function user(): ?array
{
return $this->attributes['user'] ?? null;
}
public function bearerToken(): ?string
{
$auth = $this->header('authorization', '');
if (str_starts_with($auth, 'Bearer ')) {
return substr($auth, 7);
}
return null;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Core;
final class Response
{
private int $statusCode;
private array $headers;
private string $body;
public function __construct(string $body = '', int $statusCode = 200, array $headers = [])
{
$this->body = $body;
$this->statusCode = $statusCode;
$this->headers = $headers;
}
public static function json(mixed $data, int $status = 200, array $headers = []): self
{
$headers['Content-Type'] = 'application/json; charset=utf-8';
return new self(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), $status, $headers);
}
public static function html(string $html, int $status = 200): self
{
return new self($html, $status, ['Content-Type' => 'text/html; charset=utf-8']);
}
public static function redirect(string $url, int $status = 302): self
{
return new self('', $status, ['Location' => $url]);
}
public static function download(string $filePath, string $filename): self
{
$response = new self('', 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => (string)filesize($filePath),
]);
$response->body = '__FILE__:' . $filePath;
return $response;
}
public static function sse(string $event, mixed $data): self
{
$payload = "event: {$event}\n";
$payload .= "data: " . json_encode($data) . "\n\n";
return new self($payload, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'Connection' => 'keep-alive',
]);
}
public static function noContent(): self
{
return new self('', 204);
}
public function withHeader(string $key, string $value): self
{
$this->headers[$key] = $value;
return $this;
}
public function withCookie(string $name, string $value, int $expire = 0, string $path = '/', bool $httpOnly = true, bool $secure = true, string $sameSite = 'Lax'): self
{
setcookie($name, $value, [
'expires' => $expire,
'path' => $path,
'httponly' => $httpOnly,
'secure' => $secure,
'samesite' => $sameSite,
]);
return $this;
}
public function send(): void
{
http_response_code($this->statusCode);
foreach ($this->headers as $key => $value) {
header("{$key}: {$value}");
}
if (str_starts_with($this->body, '__FILE__:')) {
$file = substr($this->body, 9);
readfile($file);
} else {
echo $this->body;
}
}
public function getStatusCode(): int { return $this->statusCode; }
public function getBody(): string { return $this->body; }
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Core;
final class Router
{
private array $routes = [];
private array $groupStack = [];
public function get(string $uri, string $controller, string $method): self
{
return $this->addRoute('GET', $uri, $controller, $method);
}
public function post(string $uri, string $controller, string $method): self
{
return $this->addRoute('POST', $uri, $controller, $method);
}
public function put(string $uri, string $controller, string $method): self
{
return $this->addRoute('PUT', $uri, $controller, $method);
}
public function delete(string $uri, string $controller, string $method): self
{
return $this->addRoute('DELETE', $uri, $controller, $method);
}
public function group(array $attributes, callable $callback): void
{
$this->groupStack[] = $attributes;
$callback($this);
array_pop($this->groupStack);
}
public function middleware(array|string $middleware): self
{
$last = array_key_last($this->routes);
if ($last !== null) {
$mw = is_array($middleware) ? $middleware : [$middleware];
$this->routes[$last]['middleware'] = array_merge(
$this->routes[$last]['middleware'] ?? [],
$mw
);
}
return $this;
}
private function addRoute(string $httpMethod, string $uri, string $controller, string $action): self
{
$prefix = '';
$middleware = [];
foreach ($this->groupStack as $group) {
if (isset($group['prefix'])) {
$prefix .= '/' . trim($group['prefix'], '/');
}
if (isset($group['middleware'])) {
$mw = is_array($group['middleware']) ? $group['middleware'] : [$group['middleware']];
$middleware = array_merge($middleware, $mw);
}
}
$fullUri = $prefix . '/' . ltrim($uri, '/');
$fullUri = '/' . trim($fullUri, '/');
if ($fullUri !== '/') {
$fullUri = rtrim($fullUri, '/');
}
$this->routes[] = [
'httpMethod' => $httpMethod,
'uri' => $fullUri,
'controller' => $controller,
'method' => $action,
'middleware' => $middleware,
'pattern' => $this->compilePattern($fullUri),
];
return $this;
}
private function compilePattern(string $uri): string
{
$pattern = preg_replace('/\{([a-zA-Z_]+)\}/', '(?P<$1>[^/]+)', $uri);
return '#^' . $pattern . '$#';
}
public function match(Request $request): ?array
{
$method = $request->method();
$uri = $request->uri();
// Support PUT/DELETE via _method field
if ($method === 'POST') {
$override = $request->input('_method');
if ($override && in_array(strtoupper($override), ['PUT', 'DELETE'])) {
$method = strtoupper($override);
}
}
foreach ($this->routes as $route) {
if ($route['httpMethod'] !== $method) {
continue;
}
if (preg_match($route['pattern'], $uri, $matches)) {
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
$route['params'] = array_values($params);
return $route;
}
}
return null;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Database;
use PDO;
final class Connection
{
private ?PDO $pdo = null;
private array $config;
public function __construct(array $config)
{
$this->config = $config;
}
public function pdo(): PDO
{
if ($this->pdo === null) {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$this->config['host'],
$this->config['port'],
$this->config['database'],
$this->config['charset']
);
$this->pdo = new PDO($dsn, $this->config['username'], $this->config['password'], $this->config['options'] ?? []);
}
return $this->pdo;
}
public function query(string $sql, array $params = []): \PDOStatement
{
$stmt = $this->pdo()->prepare($sql);
$stmt->execute($params);
return $stmt;
}
public function fetchOne(string $sql, array $params = []): ?array
{
$result = $this->query($sql, $params)->fetch();
return $result ?: null;
}
public function fetchAll(string $sql, array $params = []): array
{
return $this->query($sql, $params)->fetchAll();
}
public function fetchColumn(string $sql, array $params = []): mixed
{
return $this->query($sql, $params)->fetchColumn();
}
public function insert(string $table, array $data): int
{
$columns = implode(', ', array_map(fn($c) => "`{$c}`", array_keys($data)));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO `{$table}` ({$columns}) VALUES ({$placeholders})";
$this->query($sql, array_values($data));
return (int) $this->pdo()->lastInsertId();
}
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
$sets = implode(', ', array_map(fn($c) => "`{$c}` = ?", array_keys($data)));
$sql = "UPDATE `{$table}` SET {$sets} WHERE {$where}";
$stmt = $this->query($sql, array_merge(array_values($data), $whereParams));
return $stmt->rowCount();
}
public function delete(string $table, string $where, array $params = []): int
{
$sql = "DELETE FROM `{$table}` WHERE {$where}";
return $this->query($sql, $params)->rowCount();
}
public function beginTransaction(): void
{
$this->pdo()->beginTransaction();
}
public function commit(): void
{
$this->pdo()->commit();
}
public function rollBack(): void
{
$this->pdo()->rollBack();
}
public function transaction(callable $callback): mixed
{
$this->beginTransaction();
try {
$result = $callback($this);
$this->commit();
return $result;
} catch (\Throwable $e) {
$this->rollBack();
throw $e;
}
}
public function lastInsertId(): string
{
return $this->pdo()->lastInsertId();
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Database;
final class QueryBuilder
{
private Connection $db;
private string $table;
private array $selects = ['*'];
private array $wheres = [];
private array $bindings = [];
private array $joins = [];
private array $orderBys = [];
private array $groupBys = [];
private ?int $limitVal = null;
private ?int $offsetVal = null;
public function __construct(Connection $db, string $table)
{
$this->db = $db;
$this->table = $table;
}
public static function table(Connection $db, string $table): self
{
return new self($db, $table);
}
public function select(string ...$columns): self
{
$this->selects = $columns;
return $this;
}
public function where(string $column, string $operator, mixed $value): self
{
$this->wheres[] = "`{$column}` {$operator} ?";
$this->bindings[] = $value;
return $this;
}
public function whereNull(string $column): self
{
$this->wheres[] = "`{$column}` IS NULL";
return $this;
}
public function whereNotNull(string $column): self
{
$this->wheres[] = "`{$column}` IS NOT NULL";
return $this;
}
public function whereIn(string $column, array $values): self
{
if (empty($values)) {
$this->wheres[] = '0 = 1';
return $this;
}
$placeholders = implode(', ', array_fill(0, count($values), '?'));
$this->wheres[] = "`{$column}` IN ({$placeholders})";
$this->bindings = array_merge($this->bindings, array_values($values));
return $this;
}
public function whereRaw(string $raw, array $bindings = []): self
{
$this->wheres[] = $raw;
$this->bindings = array_merge($this->bindings, $bindings);
return $this;
}
public function join(string $table, string $first, string $operator, string $second, string $type = 'INNER'): self
{
$this->joins[] = "{$type} JOIN `{$table}` ON {$first} {$operator} {$second}";
return $this;
}
public function leftJoin(string $table, string $first, string $operator, string $second): self
{
return $this->join($table, $first, $operator, $second, 'LEFT');
}
public function orderBy(string $column, string $direction = 'ASC'): self
{
$this->orderBys[] = "`{$column}` {$direction}";
return $this;
}
public function groupBy(string ...$columns): self
{
$this->groupBys = array_merge($this->groupBys, $columns);
return $this;
}
public function limit(int $limit): self
{
$this->limitVal = $limit;
return $this;
}
public function offset(int $offset): self
{
$this->offsetVal = $offset;
return $this;
}
public function get(): array
{
return $this->db->fetchAll($this->toSql(), $this->bindings);
}
public function first(): ?array
{
$this->limitVal = 1;
return $this->db->fetchOne($this->toSql(), $this->bindings);
}
public function count(): int
{
$original = $this->selects;
$this->selects = ['COUNT(*) as cnt'];
$result = $this->first();
$this->selects = $original;
return (int)($result['cnt'] ?? 0);
}
public function sum(string $column): float
{
$original = $this->selects;
$this->selects = ["COALESCE(SUM(`{$column}`), 0) as total"];
$result = $this->first();
$this->selects = $original;
return (float)($result['total'] ?? 0);
}
public function exists(): bool
{
return $this->count() > 0;
}
public function paginate(int $page, int $perPage = 25): array
{
$total = $this->count();
$this->limitVal = $perPage;
$this->offsetVal = ($page - 1) * $perPage;
$data = $this->get();
return [
'data' => $data,
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => (int)ceil($total / $perPage),
];
}
public function toSql(): string
{
$sql = 'SELECT ' . implode(', ', $this->selects);
$sql .= " FROM `{$this->table}`";
foreach ($this->joins as $join) {
$sql .= " {$join}";
}
if (!empty($this->wheres)) {
$sql .= ' WHERE ' . implode(' AND ', $this->wheres);
}
if (!empty($this->groupBys)) {
$sql .= ' GROUP BY ' . implode(', ', array_map(fn($c) => "`{$c}`", $this->groupBys));
}
if (!empty($this->orderBys)) {
$sql .= ' ORDER BY ' . implode(', ', $this->orderBys);
}
if ($this->limitVal !== null) {
$sql .= " LIMIT {$this->limitVal}";
}
if ($this->offsetVal !== null) {
$sql .= " OFFSET {$this->offsetVal}";
}
return $sql;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Database;
final class Transaction
{
public static function run(Connection $db, callable $callback): mixed
{
return $db->transaction($callback);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Events;
final class EventDispatcher
{
private array $listeners = [];
private int $depth = 0;
private int $maxDepth = 10;
public function listen(string $event, callable $listener): void
{
$this->listeners[$event][] = $listener;
}
public function fire(string $event, array $payload = []): void
{
$this->depth++;
if ($this->depth > $this->maxDepth) {
$this->depth--;
error_log("[EventDispatcher] Max depth reached for event: {$event}");
return;
}
try {
$listeners = $this->listeners[$event] ?? [];
foreach ($listeners as $listener) {
$listener($payload);
}
// Also fire wildcard listeners
foreach ($this->listeners as $pattern => $patternListeners) {
if (str_contains($pattern, '*') && fnmatch($pattern, $event)) {
foreach ($patternListeners as $listener) {
$listener($payload);
}
}
}
} finally {
$this->depth--;
}
}
public function hasListeners(string $event): bool
{
return !empty($this->listeners[$event]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Events;
interface ListenerInterface
{
public function handle(array $payload): void;
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Export;
final class ExportManager
{
private string $exportDir;
public function __construct(string $exportDir)
{
$this->exportDir = rtrim($exportDir, '/');
if (!is_dir($this->exportDir)) {
mkdir($this->exportDir, 0755, true);
}
}
public function csv(array $data, array $headers, string $filename): string
{
$path = $this->exportDir . '/' . $filename;
$fp = fopen($path, 'w');
fputcsv($fp, $headers);
foreach ($data as $row) {
fputcsv($fp, $row);
}
fclose($fp);
return $path;
}
public function streamCsv(array $data, array $headers, string $filename): void
{
header('Content-Type: text/csv; charset=utf-8');
header("Content-Disposition: attachment; filename=\"{$filename}\"");
$fp = fopen('php://output', 'w');
fputcsv($fp, $headers);
foreach ($data as $row) {
fputcsv($fp, $row);
}
fclose($fp);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\FileStorage;
use Engine\Database\Connection;
final class FileManager
{
private string $uploadDir;
private Connection $db;
private array $allowedMimes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf', 'text/plain', 'text/csv',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip', 'application/x-zip-compressed',
];
private int $maxSize = 26214400; // 25MB
public function __construct(string $uploadDir, Connection $db)
{
$this->uploadDir = rtrim($uploadDir, '/');
$this->db = $db;
}
public function upload(array $file, int $uploadedBy): ?array
{
if ($file['error'] !== UPLOAD_ERR_OK) {
return null;
}
$mime = mime_content_type($file['tmp_name']);
if (!in_array($mime, $this->allowedMimes)) {
throw new \RuntimeException("File type not allowed: {$mime}");
}
if ($file['size'] > $this->maxSize) {
throw new \RuntimeException("File exceeds maximum size of 25MB.");
}
$date = date('Y/m/d');
$dir = $this->uploadDir . '/' . $date;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$storedName = bin2hex(random_bytes(16)) . '.' . $ext;
$storagePath = $date . '/' . $storedName;
$fullPath = $dir . '/' . $storedName;
if (!move_uploaded_file($file['tmp_name'], $fullPath)) {
throw new \RuntimeException("Failed to move uploaded file.");
}
$id = $this->db->insert('file_uploads', [
'original_name' => $file['name'],
'stored_name' => $storedName,
'mime_type' => $mime,
'size_bytes' => $file['size'],
'storage_path' => $storagePath,
'uploaded_by_id' => $uploadedBy,
]);
return [
'id' => $id,
'original_name' => $file['name'],
'stored_name' => $storedName,
'mime_type' => $mime,
'size_bytes' => $file['size'],
'storage_path' => $storagePath,
];
}
public function getPath(int $fileId): ?string
{
$file = $this->db->fetchOne("SELECT storage_path FROM file_uploads WHERE id = ?", [$fileId]);
if (!$file) return null;
return $this->uploadDir . '/' . $file['storage_path'];
}
public function getFile(int $fileId): ?array
{
return $this->db->fetchOne("SELECT * FROM file_uploads WHERE id = ?", [$fileId]);
}
public function delete(int $fileId): bool
{
$file = $this->getFile($fileId);
if (!$file) return false;
$path = $this->uploadDir . '/' . $file['storage_path'];
if (file_exists($path)) {
unlink($path);
}
$this->db->delete('file_uploads', 'id = ?', [$fileId]);
return true;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Notifications;
use Engine\Database\Connection;
final class NotificationManager
{
private Connection $db;
public function __construct(Connection $db)
{
$this->db = $db;
}
public function create(int $userId, string $tier, string $title, string $content, ?string $linkUrl = null, ?string $linkEntityType = null, ?int $linkEntityId = null): int
{
return $this->db->insert('notifications', [
'user_id' => $userId,
'tier' => $tier,
'title' => $title,
'content' => $content,
'link_url' => $linkUrl,
'link_entity_type' => $linkEntityType,
'link_entity_id' => $linkEntityId,
]);
}
public function createBlocking(int $userId, string $title, string $content, ?string $linkUrl = null, ?string $entityType = null, ?int $entityId = null): int
{
return $this->create($userId, 'blocking', $title, $content, $linkUrl, $entityType, $entityId);
}
public function createImportant(int $userId, string $title, string $content, ?string $linkUrl = null, ?string $entityType = null, ?int $entityId = null): int
{
return $this->create($userId, 'important', $title, $content, $linkUrl, $entityType, $entityId);
}
public function createInformational(int $userId, string $title, string $content, ?string $linkUrl = null): int
{
return $this->create($userId, 'informational', $title, $content, $linkUrl);
}
public function getUnreadCount(int $userId): int
{
return (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM notifications WHERE user_id = ? AND is_read = 0",
[$userId]
);
}
public function getBlockingUnacknowledged(int $userId): array
{
return $this->db->fetchAll(
"SELECT * FROM notifications WHERE user_id = ? AND tier = 'blocking' AND is_acknowledged = 0 ORDER BY created_at ASC",
[$userId]
);
}
public function hasBlocking(int $userId): bool
{
return (bool)$this->db->fetchColumn(
"SELECT COUNT(*) FROM notifications WHERE user_id = ? AND tier = 'blocking' AND is_acknowledged = 0",
[$userId]
);
}
public function getRecent(int $userId, int $limit = 20): array
{
return $this->db->fetchAll(
"SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
[$userId, $limit]
);
}
public function markAsRead(int $notificationId, int $userId): void
{
$this->db->update('notifications', [
'is_read' => 1,
'read_at' => date('Y-m-d H:i:s'),
], 'id = ? AND user_id = ?', [$notificationId, $userId]);
}
public function acknowledge(int $notificationId, int $userId): void
{
$this->db->update('notifications', [
'is_read' => 1,
'read_at' => date('Y-m-d H:i:s'),
'is_acknowledged' => 1,
'acknowledged_at' => date('Y-m-d H:i:s'),
], 'id = ? AND user_id = ?', [$notificationId, $userId]);
}
public function markAllAsRead(int $userId): void
{
$this->db->update('notifications', [
'is_read' => 1,
'read_at' => date('Y-m-d H:i:s'),
], 'user_id = ? AND is_read = 0', [$userId]);
}
public function notifyMultiple(array $userIds, string $tier, string $title, string $content, ?string $linkUrl = null, ?string $entityType = null, ?int $entityId = null): void
{
foreach ($userIds as $userId) {
$this->create($userId, $tier, $title, $content, $linkUrl, $entityType, $entityId);
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\RealTime;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Auth\SessionManager;
use Engine\Notifications\NotificationManager;
use Engine\Database\Connection;
final class SSEController
{
private SessionManager $sessions;
private NotificationManager $notifications;
private Connection $db;
public function __construct(SessionManager $sessions, NotificationManager $notifications, Connection $db)
{
$this->sessions = $sessions;
$this->notifications = $notifications;
$this->db = $db;
}
public function stream(Request $request): void
{
$user = $request->user();
if (!$user) {
http_response_code(401);
exit;
}
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
$lastCheck = time();
$timeout = 30;
$start = time();
while (true) {
if (connection_aborted()) break;
if ((time() - $start) > $timeout) break;
// Check for new notifications
$unreadCount = $this->notifications->getUnreadCount($user['id']);
$hasBlocking = $this->notifications->hasBlocking($user['id']);
echo "event: notification_count\n";
echo "data: " . json_encode(['unread_count' => $unreadCount, 'has_blocking' => $hasBlocking]) . "\n\n";
if ($hasBlocking) {
$blocking = $this->notifications->getBlockingUnacknowledged($user['id']);
if (!empty($blocking)) {
echo "event: blocking_notification\n";
echo "data: " . json_encode($blocking[0]) . "\n\n";
}
}
// HUD data for contractors
if ($user['role'] === 'contractor' && in_array($user['status'], ['active', 'on_pip', 'suspended'])) {
$hudData = $this->getHudData($user);
echo "event: hud_update\n";
echo "data: " . json_encode($hudData) . "\n\n";
}
ob_flush();
flush();
sleep(2);
}
}
private function getHudData(array $user): array
{
$month = date('Y-m');
$userId = $user['id'];
$actualSalary = (float)($user['actual_salary'] ?? 0);
$totalBounties = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?",
[$userId, $month]
);
$totalDeductions = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(COALESCE(final_amount, calculated_amount)), 0) FROM deductions
WHERE contractor_id = ? AND payroll_month = ? AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL",
[$userId, $month]
);
$totalPosAdj = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM manual_adjustments
WHERE contractor_id = ? AND effective_month = ? AND type = 'positive' AND status = 'approved' AND deleted_at IS NULL",
[$userId, $month]
);
$totalNegAdj = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM manual_adjustments
WHERE contractor_id = ? AND effective_month = ? AND type = 'negative' AND status = 'approved' AND deleted_at IS NULL",
[$userId, $month]
);
$liveSalary = $actualSalary + $totalBounties + $totalPosAdj - $totalDeductions - $totalNegAdj;
$deductionCount = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM deductions WHERE contractor_id = ? AND payroll_month = ? AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL",
[$userId, $month]
);
$bountyCount = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?",
[$userId, $month]
);
return [
'actual_salary' => $actualSalary,
'live_salary' => $liveSalary,
'total_bounties' => $totalBounties,
'total_deductions' => $totalDeductions,
'total_pos_adj' => $totalPosAdj,
'total_neg_adj' => $totalNegAdj,
'deduction_count' => $deductionCount,
'bounty_count' => $bountyCount,
'month' => $month,
];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Scheduler;
interface JobInterface
{
public function execute(): void;
public function nextRunAt(): string;
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Scheduler;
use Engine\Database\Connection;
final class JobRunner
{
private Connection $db;
private array $jobs = [];
public function __construct(Connection $db)
{
$this->db = $db;
}
public function register(string $key, JobInterface $job): void
{
$this->jobs[$key] = $job;
}
public function runDue(): array
{
$results = [];
$dueJobs = $this->db->fetchAll(
"SELECT * FROM background_jobs WHERE is_enabled = 1 AND (next_run_at IS NULL OR next_run_at <= NOW())"
);
foreach ($dueJobs as $jobRecord) {
$key = $jobRecord['job_key'];
if (!isset($this->jobs[$key])) continue;
// Lock check
if ($jobRecord['last_status'] === 'running') continue;
$this->db->update('background_jobs', ['last_status' => 'running'], 'id = ?', [$jobRecord['id']]);
try {
$this->jobs[$key]->execute();
$this->db->update('background_jobs', [
'last_run_at' => date('Y-m-d H:i:s'),
'next_run_at' => $this->jobs[$key]->nextRunAt(),
'last_status' => 'success',
'last_error' => null,
], 'id = ?', [$jobRecord['id']]);
$results[$key] = 'success';
} catch (\Throwable $e) {
$this->db->update('background_jobs', [
'last_run_at' => date('Y-m-d H:i:s'),
'next_run_at' => $this->jobs[$key]->nextRunAt(),
'last_status' => 'failed',
'last_error' => $e->getMessage(),
], 'id = ?', [$jobRecord['id']]);
$results[$key] = 'failed: ' . $e->getMessage();
}
}
return $results;
}
public function initializeJob(string $key): void
{
$exists = $this->db->fetchOne("SELECT id FROM background_jobs WHERE job_key = ?", [$key]);
if (!$exists) {
$this->db->insert('background_jobs', [
'job_key' => $key,
'is_enabled' => 1,
'next_run_at' => date('Y-m-d H:i:s'),
]);
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Search;
use Engine\Database\Connection;
final class SearchEngine
{
private Connection $db;
public function __construct(Connection $db)
{
$this->db = $db;
}
public function search(string $query, array $user, int $limit = 50): array
{
$results = [];
$q = '%' . $query . '%';
// Cards
$cards = $this->db->fetchAll(
"SELECT c.id, c.card_key, c.title, c.board_id, b.name as board_name
FROM cards c
JOIN boards b ON b.id = c.board_id
JOIN board_members bm ON bm.board_id = b.id AND bm.user_id = ?
WHERE (c.title LIKE ? OR c.card_key LIKE ? OR c.description LIKE ?) AND c.is_archived = 0
LIMIT ?",
[$user['id'], $q, $q, $q, $limit]
);
foreach ($cards as $card) {
$results[] = [
'type' => 'card',
'id' => $card['id'],
'title' => $card['card_key'] . ': ' . $card['title'],
'context' => $card['board_name'],
'url' => "/boards/{$card['board_id']}/cards/{$card['id']}",
];
}
// Users (if allowed)
if (in_array($user['role'], ['super_admin', 'admin', 'project_leader'])) {
$users = $this->db->fetchAll(
"SELECT id, full_name_en, username, role FROM users WHERE (full_name_en LIKE ? OR username LIKE ?) AND status != 'terminated' LIMIT ?",
[$q, $q, $limit]
);
foreach ($users as $u) {
$results[] = [
'type' => 'user',
'id' => $u['id'],
'title' => $u['full_name_en'],
'context' => '@' . $u['username'] . ' · ' . $u['role'],
'url' => "/users/{$u['id']}",
];
}
}
// Boards
$boards = $this->db->fetchAll(
"SELECT b.id, b.name FROM boards b
JOIN board_members bm ON bm.board_id = b.id AND bm.user_id = ?
WHERE b.name LIKE ? AND b.is_archived = 0
LIMIT ?",
[$user['id'], $q, $limit]
);
foreach ($boards as $board) {
$results[] = [
'type' => 'board',
'id' => $board['id'],
'title' => $board['name'],
'context' => 'Board',
'url' => "/boards/{$board['id']}",
];
}
return $results;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\StateMachine;
final class StateDefinition
{
public static function contractorStatus(): array
{
return [
'states' => ['onboarding', 'active', 'on_pip', 'suspended', 'terminated'],
'transitions' => [
['from' => 'onboarding', 'to' => 'active'],
['from' => 'active', 'to' => 'on_pip'],
['from' => 'on_pip', 'to' => 'active'],
['from' => 'on_pip', 'to' => 'terminated'],
['from' => 'active', 'to' => 'suspended'],
['from' => 'suspended', 'to' => 'active'],
['from' => 'active', 'to' => 'terminated'],
['from' => 'suspended', 'to' => 'terminated'],
],
];
}
public static function deductionStatus(): array
{
return [
'states' => [
'draft_pending_admin', 'pending_acknowledgment', 'acknowledged',
'pending_response', 'accepted', 'disputed', 'under_review',
'applied', 'applied_no_response', 'reduced', 'dismissed'
],
'transitions' => [
['from' => 'draft_pending_admin', 'to' => 'pending_acknowledgment'],
['from' => 'draft_pending_admin', 'to' => 'dismissed'],
['from' => 'pending_acknowledgment', 'to' => 'acknowledged'],
['from' => 'acknowledged', 'to' => 'accepted'],
['from' => 'acknowledged', 'to' => 'disputed'],
['from' => 'acknowledged', 'to' => 'applied_no_response'],
['from' => 'disputed', 'to' => 'under_review'],
['from' => 'under_review', 'to' => 'applied'],
['from' => 'under_review', 'to' => 'reduced'],
['from' => 'under_review', 'to' => 'dismissed'],
['from' => 'accepted', 'to' => 'applied'],
],
];
}
public static function reportStatus(): array
{
return [
'states' => [
'draft', 'submitted', 'late', 'approved', 'approved_auto',
'flagged_vague', 'flagged_inconsistent', 'revision_requested', 'amended', 'unreported'
],
'transitions' => [
['from' => 'draft', 'to' => 'submitted'],
['from' => 'draft', 'to' => 'late'],
['from' => 'submitted', 'to' => 'approved'],
['from' => 'submitted', 'to' => 'approved_auto'],
['from' => 'submitted', 'to' => 'flagged_vague'],
['from' => 'submitted', 'to' => 'flagged_inconsistent'],
['from' => 'submitted', 'to' => 'revision_requested'],
['from' => 'late', 'to' => 'approved'],
['from' => 'revision_requested', 'to' => 'amended'],
],
];
}
public static function payrollStatus(): array
{
return [
'states' => [
'pending_calculation', 'calculated', 'under_review',
'submitted', 'approved', 'rejected', 'processing', 'paid'
],
'transitions' => [
['from' => 'pending_calculation', 'to' => 'calculated'],
['from' => 'calculated', 'to' => 'under_review'],
['from' => 'under_review', 'to' => 'submitted'],
['from' => 'submitted', 'to' => 'approved'],
['from' => 'submitted', 'to' => 'rejected'],
['from' => 'rejected', 'to' => 'under_review'],
['from' => 'approved', 'to' => 'processing'],
['from' => 'processing', 'to' => 'paid'],
],
];
}
public static function pipStatus(): array
{
return [
'states' => ['created', 'acknowledged', 'active', 'passed', 'failed'],
'transitions' => [
['from' => 'created', 'to' => 'acknowledged'],
['from' => 'acknowledged', 'to' => 'active'],
['from' => 'active', 'to' => 'passed'],
['from' => 'active', 'to' => 'failed'],
],
];
}
public static function inviteStatus(): array
{
return [
'states' => ['active', 'used', 'expired', 'revoked'],
'transitions' => [
['from' => 'active', 'to' => 'used'],
['from' => 'active', 'to' => 'expired'],
['from' => 'active', 'to' => 'revoked'],
],
];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\StateMachine;
use Engine\Audit\AuditLogger;
final class StateMachine
{
private AuditLogger $audit;
private array $definitions = [];
public function __construct(AuditLogger $audit)
{
$this->audit = $audit;
}
public function define(string $entity, array $definition): void
{
$this->definitions[$entity] = $definition;
}
public function canTransition(string $entity, string $from, string $to): bool
{
$def = $this->definitions[$entity] ?? null;
if (!$def) return false;
$transitions = $def['transitions'] ?? [];
foreach ($transitions as $t) {
if ($t['from'] === $from && $t['to'] === $to) {
return true;
}
if ($t['from'] === '*' && $t['to'] === $to) {
return true;
}
}
return false;
}
public function transition(string $entity, string $from, string $to, array $context = []): bool
{
if (!$this->canTransition($entity, $from, $to)) {
return false;
}
$transitions = $this->definitions[$entity]['transitions'] ?? [];
foreach ($transitions as $t) {
$fromMatch = ($t['from'] === $from || $t['from'] === '*');
if ($fromMatch && $t['to'] === $to) {
// Execute guard if present
if (isset($t['guard']) && is_callable($t['guard'])) {
if (!($t['guard'])($context)) {
return false;
}
}
// Execute side effects
if (isset($t['onTransition']) && is_callable($t['onTransition'])) {
($t['onTransition'])($context);
}
return true;
}
}
return false;
}
public function getStates(string $entity): array
{
return $this->definitions[$entity]['states'] ?? [];
}
public function getAvailableTransitions(string $entity, string $currentState): array
{
$available = [];
$transitions = $this->definitions[$entity]['transitions'] ?? [];
foreach ($transitions as $t) {
if ($t['from'] === $currentState || $t['from'] === '*') {
$available[] = $t['to'];
}
}
return array_unique($available);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Template;
final class TemplateEngine
{
private string $templateDir;
private string $cacheDir;
private array $globals = [];
private array $sections = [];
private array $sectionStack = [];
private ?string $layout = null;
private string $childContent = '';
public function __construct(string $templateDir, string $cacheDir)
{
$this->templateDir = rtrim($templateDir, '/');
$this->cacheDir = rtrim($cacheDir, '/');
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
}
}
public function addGlobal(string $key, mixed $value): void
{
$this->globals[$key] = $value;
}
public function render(string $template, array $data = []): string
{
$this->sections = [];
$this->layout = null;
$content = $this->renderFile($template, $data);
if ($this->layout) {
$layoutTemplate = $this->layout;
$this->childContent = $content;
$this->layout = null;
$content = $this->renderFile($layoutTemplate, $data);
}
return $content;
}
private function renderFile(string $template, array $data): string
{
$file = $this->templateDir . '/' . str_replace('.', '/', $template) . '.php';
if (!file_exists($file)) {
throw new \RuntimeException("Template not found: {$file}");
}
extract(array_merge($this->globals, $data));
$__engine = $this;
ob_start();
try {
include $file;
} catch (\Throwable $e) {
ob_end_clean();
throw $e;
}
return ob_get_clean();
}
public function extend(string $layout): void
{
$this->layout = $layout;
}
public function section(string $name): void
{
$this->sectionStack[] = $name;
ob_start();
}
public function endSection(): void
{
$name = array_pop($this->sectionStack);
$this->sections[$name] = ob_get_clean();
}
public function yield(string $name, string $default = ''): string
{
return $this->sections[$name] ?? $default;
}
public function content(): string
{
return $this->childContent;
}
public function partial(string $template, array $data = []): string
{
return $this->renderFile($template, $data);
}
public function e(mixed $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
public function csrf(): string
{
$token = $_SESSION['csrf_token'] ?? ($_COOKIE['csrf_token'] ?? '');
return '<input type="hidden" name="_csrf_token" value="' . $this->e($token) . '">';
}
public function csrfMeta(): string
{
$token = $_SESSION['csrf_token'] ?? ($_COOKIE['csrf_token'] ?? '');
return '<meta name="csrf-token" content="' . $this->e($token) . '">';
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Engine\Validation;
use Engine\Database\Connection;
final class Validator
{
private Connection $db;
private array $errors = [];
public function __construct(Connection $db)
{
$this->db = $db;
}
public function validate(array $data, array $rules): bool
{
$this->errors = [];
foreach ($rules as $field => $ruleSet) {
$value = $data[$field] ?? null;
$ruleList = is_string($ruleSet) ? explode('|', $ruleSet) : $ruleSet;
foreach ($ruleList as $rule) {
$params = [];
if (str_contains($rule, ':')) {
[$rule, $paramStr] = explode(':', $rule, 2);
$params = explode(',', $paramStr);
}
$error = $this->applyRule($field, $value, $rule, $params, $data);
if ($error !== null) {
$this->errors[$field][] = $error;
}
}
}
return empty($this->errors);
}
private function applyRule(string $field, mixed $value, string $rule, array $params, array $data): ?string
{
$label = str_replace('_', ' ', $field);
return match($rule) {
'required' => ($value === null || $value === '' || $value === [])
? "{$label} is required." : null,
'string' => (!is_string($value) && $value !== null)
? "{$label} must be a string." : null,
'integer' => (!is_numeric($value) && $value !== null)
? "{$label} must be a number." : null,
'numeric' => (!is_numeric($value) && $value !== null)
? "{$label} must be numeric." : null,
'email' => ($value && !filter_var($value, FILTER_VALIDATE_EMAIL))
? "{$label} must be a valid email." : null,
'min' => (is_string($value) && strlen($value) < (int)$params[0])
? "{$label} must be at least {$params[0]} characters." : null,
'max' => (is_string($value) && strlen($value) > (int)$params[0])
? "{$label} must not exceed {$params[0]} characters." : null,
'min_value' => (is_numeric($value) && (float)$value < (float)$params[0])
? "{$label} must be at least {$params[0]}." : null,
'max_value' => (is_numeric($value) && (float)$value > (float)$params[0])
? "{$label} must not exceed {$params[0]}." : null,
'in' => ($value !== null && !in_array($value, $params, true))
? "{$label} must be one of: " . implode(', ', $params) : null,
'unique' => $this->checkUnique($field, $value, $params),
'confirmed' => ($value !== ($data["{$field}_confirmation"] ?? ($data['confirm_' . $field] ?? null)))
? "{$label} confirmation does not match." : null,
'date' => ($value && !strtotime($value))
? "{$label} must be a valid date." : null,
'regex' => ($value && !preg_match($params[0], $value))
? "{$label} format is invalid." : null,
'egyptian_phone' => ($value && !preg_match('/^01[0-9]{9}$/', $value))
? "{$label} must be a valid Egyptian phone number." : null,
'national_id' => ($value && !preg_match('/^[0-9]{14}$/', $value))
? "{$label} must be a valid 14-digit national ID." : null,
'min_age' => $this->checkMinAge($field, $value, (int)($params[0] ?? 16)),
'password_strength' => $this->checkPasswordStrength($field, $value),
'matches' => ($value !== ($data[$params[0]] ?? null))
? "{$label} must match {$params[0]}." : null,
'json' => ($value && json_decode($value) === null && json_last_error() !== JSON_ERROR_NONE)
? "{$label} must be valid JSON." : null,
'array' => (!is_array($value) && $value !== null)
? "{$label} must be an array." : null,
'boolean' => (!in_array($value, [true, false, 0, 1, '0', '1'], true) && $value !== null)
? "{$label} must be true or false." : null,
default => null,
};
}
private function checkUnique(string $field, mixed $value, array $params): ?string
{
if ($value === null || $value === '') return null;
$table = $params[0] ?? '';
$column = $params[1] ?? $field;
$exceptId = $params[2] ?? null;
$sql = "SELECT COUNT(*) FROM `{$table}` WHERE `{$column}` = ?";
$bindings = [$value];
if ($exceptId) {
$sql .= " AND id != ?";
$bindings[] = $exceptId;
}
$count = (int)$this->db->fetchColumn($sql, $bindings);
return $count > 0 ? str_replace('_', ' ', $field) . " already exists." : null;
}
private function checkMinAge(string $field, mixed $value, int $minAge): ?string
{
if (!$value) return null;
$dob = new \DateTime($value);
$now = new \DateTime();
$age = $now->diff($dob)->y;
return $age < $minAge ? str_replace('_', ' ', $field) . " must indicate at least {$minAge} years of age." : null;
}
private function checkPasswordStrength(string $field, mixed $value): ?string
{
if (!$value) return null;
if (strlen($value) < 10) return "Password must be at least 10 characters.";
if (!preg_match('/[A-Z]/', $value)) return "Password must contain at least one uppercase letter.";
if (!preg_match('/[a-z]/', $value)) return "Password must contain at least one lowercase letter.";
if (!preg_match('/[0-9]/', $value)) return "Password must contain at least one number.";
if (!preg_match('/[^A-Za-z0-9]/', $value)) return "Password must contain at least one special character.";
return null;
}
public function errors(): array
{
return $this->errors;
}
public function firstError(): ?string
{
foreach ($this->errors as $fieldErrors) {
return $fieldErrors[0] ?? null;
}
return null;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Middleware;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Core\Container;
use Engine\Audit\AuditLogger;
final class AuditMiddleware
{
private AuditLogger $audit;
public function __construct()
{
$this->audit = Container::getInstance()->resolve(AuditLogger::class);
}
public function handle(Request $request, callable $next): Response
{
$response = $next($request);
$user = $request->user();
$method = $request->method();
if (in_array($method, ['POST', 'PUT', 'DELETE'])) {
$this->audit->log(
$user,
$method,
'http_request',
null,
'system',
$request->uri(),
null,
null,
$request->ip(),
$request->userAgent()
);
}
return $response;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Middleware;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Core\Container;
use Engine\Auth\SessionManager;
final class AuthenticationMiddleware
{
private SessionManager $sessions;
public function __construct()
{
$this->sessions = Container::getInstance()->resolve(SessionManager::class);
}
public function handle(Request $request, callable $next): Response
{
$user = $this->sessions->validate();
if (!$user) {
if ($request->wantsJson()) {
return Response::json(['error' => 'Unauthenticated'], 401);
}
return Response::redirect('/login');
}
$request->setAttribute('user', $user);
return $next($request);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Middleware;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Core\Container;
use Engine\Notifications\NotificationManager;
final class BlockingNotificationMiddleware
{
private NotificationManager $notifications;
public function __construct()
{
$this->notifications = Container::getInstance()->resolve(NotificationManager::class);
}
public function handle(Request $request, callable $next): Response
{
$user = $request->user();
if (!$user) {
return $next($request);
}
// Allow notification endpoints through
$uri = $request->uri();
if (str_starts_with($uri, '/api/notifications') || str_starts_with($uri, '/notifications') || str_starts_with($uri, '/sse') || str_starts_with($uri, '/login') || str_starts_with($uri, '/logout')) {
return $next($request);
}
if ($this->notifications->hasBlocking($user['id'])) {
if ($request->wantsJson()) {
$blocking = $this->notifications->getBlockingUnacknowledged($user['id']);
return Response::json([
'blocked' => true,
'notification' => $blocking[0] ?? null,
], 403);
}
// For HTML requests, redirect to blocking notification page
return Response::redirect('/notifications/blocking');
}
return $next($request);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Middleware;
use Engine\Core\Request;
use Engine\Core\Response;
final class CORSMiddleware
{
public function handle(Request $request, callable $next): Response
{
if ($request->method() === 'OPTIONS') {
return (new Response('', 204))
->withHeader('Access-Control-Allow-Origin', '*')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-CSRF-Token, X-Requested-With')
->withHeader('Access-Control-Max-Age', '86400');
}
$response = $next($request);
return $response
->withHeader('Access-Control-Allow-Origin', '*')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Middleware;
use Engine\Core\Request;
use Engine\Core\Response;
final class CSRFMiddleware
{
public function handle(Request $request, callable $next): Response
{
if (in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'])) {
$this->ensureToken();
return $next($request);
}
$token = $request->input('_csrf_token') ?? $request->header('x-csrf-token');
$sessionToken = $_COOKIE['csrf_token'] ?? '';
if (!$token || !$sessionToken || !hash_equals($sessionToken, $token)) {
if ($request->wantsJson()) {
return Response::json(['error' => 'CSRF token mismatch'], 419);
}
return Response::html('<h1>419 - Page Expired</h1><p>CSRF token mismatch. Please refresh.</p>', 419);
}
return $next($request);
}
private function ensureToken(): void
{
if (!isset($_COOKIE['csrf_token'])) {
$token = bin2hex(random_bytes(32));
setcookie('csrf_token', $token, [
'expires' => 0,
'path' => '/',
'httponly' => false, // JS needs to read it
'secure' => isset($_SERVER['HTTPS']),
'samesite' => 'Lax',
]);
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Middleware;
use Engine\Core\Request;
use Engine\Core\Response;
final class JsonBodyParserMiddleware
{
public function handle(Request $request, callable $next): Response
{
// Body already parsed in Request::capture()
return $next($request);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Auth\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Auth\Authenticator;
use Engine\Audit\AuditLogger;
use Engine\Template\TemplateEngine;
final class LoginController
{
private Authenticator $auth;
private AuditLogger $audit;
private TemplateEngine $templates;
public function __construct()
{
$c = Container::getInstance();
$this->auth = $c->resolve(Authenticator::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function showForm(Request $request): Response
{
return Response::html($this->templates->render('auth/login', [
'error' => $request->query('error'),
]));
}
public function login(Request $request): Response
{
$username = trim($request->input('username', ''));
$password = $request->input('password', '');
if (!$username || !$password) {
if ($request->wantsJson()) {
return Response::json(['error' => 'Username and password are required.'], 422);
}
return Response::redirect('/login?error=' . urlencode('Username and password are required.'));
}
$result = $this->auth->attempt($username, $password, $request->ip(), $request->userAgent());
if (!$result['success']) {
if ($request->wantsJson()) {
return Response::json(['error' => $result['error']], 401);
}
return Response::redirect('/login?error=' . urlencode($result['error']));
}
$this->audit->log(
['id' => $result['user_id'], 'username' => $username, 'role' => $result['role']],
'LOGIN', 'user', $result['user_id'], 'auth', '/login',
null, null, $request->ip(), $request->userAgent()
);
if ($request->wantsJson()) {
return Response::json([
'success' => true,
'force_password_change' => $result['force_password_change'],
'redirect' => $result['force_password_change'] ? '/password/change' : '/dashboard',
]);
}
if ($result['force_password_change']) {
return Response::redirect('/password/change');
}
return Response::redirect('/dashboard');
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Auth\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Auth\SessionManager;
use Engine\Audit\AuditLogger;
final class LogoutController
{
private SessionManager $sessions;
private AuditLogger $audit;
public function __construct()
{
$c = Container::getInstance();
$this->sessions = $c->resolve(SessionManager::class);
$this->audit = $c->resolve(AuditLogger::class);
}
public function logout(Request $request): Response
{
$user = $request->user();
if ($user) {
$this->audit->log($user, 'LOGOUT', 'user', $user['id'], 'auth', '/logout', null, null, $request->ip(), $request->userAgent());
}
$this->sessions->destroy();
if ($request->wantsJson()) {
return Response::json(['success' => true]);
}
return Response::redirect('/login');
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Auth\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Auth\PasswordHasher;
use Engine\Auth\SessionManager;
use Engine\Database\Connection;
use Engine\Validation\Validator;
use Engine\Audit\AuditLogger;
use Engine\Template\TemplateEngine;
final class PasswordController
{
private Connection $db;
private PasswordHasher $hasher;
private SessionManager $sessions;
private Validator $validator;
private AuditLogger $audit;
private TemplateEngine $templates;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->hasher = $c->resolve(PasswordHasher::class);
$this->sessions = $c->resolve(SessionManager::class);
$this->validator = $c->resolve(Validator::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function showChangeForm(Request $request): Response
{
return Response::html($this->templates->render('auth/change_password', [
'user' => $request->user(),
'error' => $request->query('error'),
'forced' => (bool)($request->user()['force_password_change'] ?? false),
]));
}
public function change(Request $request): Response
{
$user = $request->user();
$valid = $this->validator->validate($request->all(), [
'current_password' => 'required',
'new_password' => 'required|min:10|password_strength',
'confirm_password' => 'required|matches:new_password',
]);
if (!$valid) {
if ($request->wantsJson()) {
return Response::json(['errors' => $this->validator->errors()], 422);
}
return Response::redirect('/password/change?error=' . urlencode($this->validator->firstError()));
}
$dbUser = $this->db->fetchOne('SELECT password_hash FROM users WHERE id = ?', [$user['id']]);
if (!$this->hasher->verify($request->input('current_password'), $dbUser['password_hash'])) {
if ($request->wantsJson()) {
return Response::json(['error' => 'Current password is incorrect.'], 422);
}
return Response::redirect('/password/change?error=' . urlencode('Current password is incorrect.'));
}
$newHash = $this->hasher->hash($request->input('new_password'));
$this->db->update('users', [
'password_hash' => $newHash,
'force_password_change' => 0,
'temp_password_hash' => null,
'temp_password_expires_at' => null,
], 'id = ?', [$user['id']]);
// Kill other sessions
$this->sessions->destroyAllForUser($user['id'], $user['session_id'] ?? null);
$this->audit->log($user, 'PASSWORD_CHANGED', 'user', $user['id'], 'auth', '/password/change', null, null, $request->ip(), $request->userAgent());
if ($request->wantsJson()) {
return Response::json(['success' => true, 'redirect' => '/dashboard']);
}
return Response::redirect('/dashboard');
}
public function resetForUser(Request $request, string $userId): Response
{
$user = $request->user();
if ($user['role'] !== 'super_admin') {
return Response::json(['error' => 'Forbidden'], 403);
}
$targetUser = $this->db->fetchOne('SELECT * FROM users WHERE id = ?', [(int)$userId]);
if (!$targetUser) {
return Response::json(['error' => 'User not found'], 404);
}
$tempPassword = bin2hex(random_bytes(6)); // 12 chars
$this->db->update('users', [
'temp_password_hash' => $this->hasher->hash($tempPassword),
'temp_password_expires_at' => date('Y-m-d H:i:s', strtotime('+24 hours')),
'force_password_change' => 1,
], 'id = ?', [(int)$userId]);
$this->audit->log($user, 'PASSWORD_RESET', 'user', (int)$userId, 'auth', '/users/' . $userId . '/reset-password', null, null, $request->ip(), $request->userAgent());
return Response::json([
'success' => true,
'temp_password' => $tempPassword,
'expires_in' => '24 hours',
'message' => 'Communicate this password to the user through internal messaging. It will expire in 24 hours.',
]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Auth\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Auth\SessionManager;
final class SessionCleanupJob implements JobInterface
{
private SessionManager $sessions;
public function __construct()
{
$this->sessions = Container::getInstance()->resolve(SessionManager::class);
}
public function execute(): void
{
$this->sessions->cleanup();
}
public function nextRunAt(): string
{
return date('Y-m-d H:i:s', strtotime('+1 day'));
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->get('/login', Modules\Auth\Controllers\LoginController::class, 'showForm');
$router->post('/login', Modules\Auth\Controllers\LoginController::class, 'login');
$router->group(['middleware' => [Middleware\AuthenticationMiddleware::class]], function ($router) {
$router->post('/logout', Modules\Auth\Controllers\LogoutController::class, 'logout');
$router->get('/password/change', Modules\Auth\Controllers\PasswordController::class, 'showChangeForm');
$router->post('/password/change', Modules\Auth\Controllers\PasswordController::class, 'change');
$router->post('/users/{userId}/reset-password', Modules\Auth\Controllers\PasswordController::class, 'resetForUser');
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Cards\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
final class AutoArchiveDoneCardsJob implements JobInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function execute(): void
{
$boards = $this->db->fetchAll("SELECT id, auto_archive_done_days FROM boards WHERE is_archived = 0");
foreach ($boards as $board) {
$days = $board['auto_archive_done_days'];
$cutoff = date('Y-m-d H:i:s', strtotime("-{$days} days"));
$this->db->query(
"UPDATE cards c
JOIN board_columns bc ON bc.id = c.column_id
SET c.is_archived = 1, c.archived_at = NOW()
WHERE c.board_id = ? AND bc.slug = 'done' AND c.done_at IS NOT NULL AND c.done_at < ? AND c.is_archived = 0",
[$board['id'], $cutoff]
);
}
}
public function nextRunAt(): string
{
return date('Y-m-d 03:00:00', strtotime('+1 day'));
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Cards\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class SendDeadlineRemindersJob implements JobInterface
{
private Connection $db;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function execute(): void
{
// Cards due in 2 days
$twoDays = date('Y-m-d', strtotime('+2 days'));
$cards = $this->db->fetchAll(
"SELECT c.id, c.card_key, c.title, c.deadline FROM cards c
WHERE DATE(c.deadline) = ? AND c.done_at IS NULL AND c.is_archived = 0",
[$twoDays]
);
foreach ($cards as $card) {
$assignees = $this->db->fetchAll("SELECT user_id FROM card_assignments WHERE card_id = ?", [$card['id']]);
foreach ($assignees as $a) {
$this->notif->createImportant(
$a['user_id'],
'⏰ Deadline in 2 Days',
"{$card['card_key']}: {$card['title']} is due in 2 days.",
"/cards/{$card['id']}", 'card', $card['id']
);
}
}
// Cards due today
$today = date('Y-m-d');
$todayCards = $this->db->fetchAll(
"SELECT c.id, c.card_key, c.title FROM cards c
WHERE DATE(c.deadline) = ? AND c.done_at IS NULL AND c.is_archived = 0",
[$today]
);
foreach ($todayCards as $card) {
$assignees = $this->db->fetchAll("SELECT user_id FROM card_assignments WHERE card_id = ?", [$card['id']]);
foreach ($assignees as $a) {
$this->notif->createImportant(
$a['user_id'],
'⏰ Deadline TODAY',
"{$card['card_key']}: {$card['title']} is due TODAY!",
"/cards/{$card['id']}", 'card', $card['id']
);
}
}
}
public function nextRunAt(): string
{
return date('Y-m-d 08:00:00', strtotime('+1 day'));
}
}
\ No newline at end of file
This diff is collapsed.
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '',
'middleware' => [
Middleware\AuthenticationMiddleware::class,
Middleware\CSRFMiddleware::class,
Middleware\BlockingNotificationMiddleware::class,
]
], function ($router) {
$router->get('/dashboard', Modules\Dashboard\Controllers\DashboardController::class, 'index');
$router->get('/', Modules\Dashboard\Controllers\DashboardController::class, 'index');
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Deductions\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
final class AutoApplyExpiredDeductionsJob implements JobInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function execute(): void
{
// Apply deductions where response window has expired with no response
$this->db->query(
"UPDATE deductions
SET status = 'applied_no_response',
final_amount = calculated_amount,
applied_at = NOW(),
payroll_month = DATE_FORMAT(NOW(), '%Y-%m')
WHERE status = 'acknowledged'
AND response_deadline IS NOT NULL
AND response_deadline < NOW()
AND deleted_at IS NULL"
);
}
public function nextRunAt(): string
{
return date('Y-m-d H:i:s', strtotime('+1 hour'));
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Notifications\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Notifications\NotificationManager;
use Engine\Database\Connection;
use Engine\Template\TemplateEngine;
final class NotificationController
{
private NotificationManager $notifManager;
private Connection $db;
private TemplateEngine $templates;
public function __construct()
{
$c = Container::getInstance();
$this->notifManager = $c->resolve(NotificationManager::class);
$this->db = $c->resolve(Connection::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function index(Request $request): Response
{
$user = $request->user();
$page = (int)($request->query('page') ?? 1);
$notifications = $this->db->fetchAll(
"SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50 OFFSET ?",
[$user['id'], ($page - 1) * 50]
);
if ($request->wantsJson()) {
return Response::json([
'notifications' => $notifications,
'unread_count' => $this->notifManager->getUnreadCount($user['id']),
]);
}
return Response::html($this->templates->render('notifications/index', [
'user' => $user,
'notifications' => $notifications,
'unread_count' => $this->notifManager->getUnreadCount($user['id']),
]));
}
public function recent(Request $request): Response
{
$user = $request->user();
return Response::json([
'notifications' => $this->notifManager->getRecent($user['id'], 20),
'unread_count' => $this->notifManager->getUnreadCount($user['id']),
]);
}
public function markRead(Request $request, string $id): Response
{
$this->notifManager->markAsRead((int)$id, $request->user()['id']);
return Response::json(['success' => true]);
}
public function markAllRead(Request $request): Response
{
$this->notifManager->markAllAsRead($request->user()['id']);
return Response::json(['success' => true]);
}
public function acknowledge(Request $request, string $id): Response
{
$this->notifManager->acknowledge((int)$id, $request->user()['id']);
return Response::json(['success' => true]);
}
public function showBlocking(Request $request): Response
{
$user = $request->user();
$blocking = $this->notifManager->getBlockingUnacknowledged($user['id']);
if (empty($blocking)) {
return Response::redirect('/dashboard');
}
return Response::html($this->templates->render('notifications/blocking', [
'user' => $user,
'notification' => $blocking[0],
]));
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/notifications',
'middleware' => [Middleware\AuthenticationMiddleware::class]
], function ($router) {
$router->get('/', Modules\Notifications\Controllers\NotificationController::class, 'index');
$router->get('/recent', Modules\Notifications\Controllers\NotificationController::class, 'recent');
$router->get('/blocking', Modules\Notifications\Controllers\NotificationController::class, 'showBlocking');
$router->post('/{id}/read', Modules\Notifications\Controllers\NotificationController::class, 'markRead');
$router->post('/read-all', Modules\Notifications\Controllers\NotificationController::class, 'markAllRead');
$router->post('/{id}/acknowledge', Modules\Notifications\Controllers\NotificationController::class, 'acknowledge');
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Onboarding\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
final class InviteExpiryJob implements JobInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function execute(): void
{
$this->db->query(
"UPDATE invites SET status = 'expired' WHERE status = 'active' AND expires_at < NOW()"
);
}
public function nextRunAt(): string
{
return date('Y-m-d 00:05:00', strtotime('+1 day'));
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Reports\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class DetectUnreportedDaysJob implements JobInterface
{
private Connection $db;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function execute(): void
{
$yesterday = date('Y-m-d', strtotime('-1 day'));
$dayOfWeek = (int)date('w', strtotime($yesterday));
// Get all active contractors
$contractors = $this->db->fetchAll(
"SELECT u.id, u.full_name_en FROM users u WHERE u.role = 'contractor' AND u.status = 'active'"
);
foreach ($contractors as $contractor) {
$userId = $contractor['id'];
// Check if yesterday was a working day
$isWorkDay = $this->db->fetchOne(
"SELECT 1 FROM user_schedule_days WHERE user_id = ? AND day_of_week = ? AND work_mode != 'off' AND effective_to IS NULL",
[$userId, $dayOfWeek]
);
if (!$isWorkDay) continue;
// Check holiday
$isHoliday = $this->db->fetchOne(
"SELECT 1 FROM holidays WHERE start_date <= ? AND end_date >= ?",
[$yesterday, $yesterday]
);
if ($isHoliday) continue;
// Check unavailability
$isUnavailable = $this->db->fetchOne(
"SELECT 1 FROM unavailability_records WHERE user_id = ? AND start_date <= ? AND end_date >= ?",
[$userId, $yesterday, $yesterday]
);
if ($isUnavailable) continue;
// Check if report exists
$hasReport = $this->db->fetchOne(
"SELECT 1 FROM daily_reports WHERE user_id = ? AND report_date = ?",
[$userId, $yesterday]
);
if ($hasReport) continue;
// Create unreported record
$this->db->insert('daily_reports', [
'user_id' => $userId,
'report_date' => $yesterday,
'status' => 'unreported',
]);
// Notify contractor
$this->notif->createImportant(
$userId,
'Unreported Day',
"You have an unreported day: {$yesterday}. A deduction may be initiated."
);
}
}
public function nextRunAt(): string
{
return date('Y-m-d 01:00:00', strtotime('+1 day'));
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->get('/sse/stream', Engine\RealTime\SSEController::class, 'stream')
->middleware([Middleware\AuthenticationMiddleware::class]);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Salary\Calculators;
use Engine\Calculation\CalculatorInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
final class BaseSalaryCalculator implements CalculatorInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function calculate(array $context): mixed
{
$userId = $context['user_id'];
$contractorType = $context['contractor_type'] ?? null;
// Get current schedule
$schedule = $this->db->fetchAll(
"SELECT day_of_week, work_mode FROM user_schedule_days
WHERE user_id = ? AND effective_to IS NULL AND work_mode != 'off'",
[$userId]
);
if (!$contractorType) {
$user = $this->db->fetchOne("SELECT contractor_type FROM users WHERE id = ?", [$userId]);
$contractorType = $user['contractor_type'] ?? 'full_timer';
}
// Get day rates from system settings
$rates = $this->getDayRates($contractorType);
$baseSalary = 0.00;
foreach ($schedule as $day) {
if ($day['work_mode'] === 'in_office') {
$baseSalary += $rates['in_office'];
} elseif ($day['work_mode'] === 'remote') {
$baseSalary += $rates['remote'];
}
}
return round($baseSalary, 2);
}
private function getDayRates(string $contractorType): array
{
$prefix = $contractorType === 'intern' ? 'intern' : 'full_timer';
$inOffice = $this->db->fetchOne(
"SELECT `value` FROM system_settings WHERE `key` = ?",
["{$prefix}_in_office_day_rate"]
);
$remote = $this->db->fetchOne(
"SELECT `value` FROM system_settings WHERE `key` = ?",
["{$prefix}_remote_day_rate"]
);
return [
'in_office' => (float)($inOffice['value'] ?? ($contractorType === 'intern' ? 1000 : 2400)),
'remote' => (float)($remote['value'] ?? ($contractorType === 'intern' ? 500 : 1600)),
];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Salary\Calculators;
use Engine\Calculation\CalculatorInterface;
use Engine\Core\Container;
final class DailyRateCalculator implements CalculatorInterface
{
public function calculate(array $context): mixed
{
$actualSalary = (float)($context['actual_salary'] ?? 0);
$expectedWorkingDays = (int)($context['expected_working_days'] ?? 22);
if ($expectedWorkingDays <= 0) return 0;
return round($actualSalary / $expectedWorkingDays, 2);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Salary\Calculators;
use Engine\Calculation\CalculatorInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
final class ExpectedWorkingDaysCalculator implements CalculatorInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function calculate(array $context): mixed
{
$userId = $context['user_id'];
$month = $context['month'] ?? date('Y-m');
$year = (int)substr($month, 0, 4);
$mon = (int)substr($month, 5, 2);
$daysInMonth = cal_days_in_month(CAL_GREGORIAN, $mon, $year);
// Get working days from schedule
$schedule = $this->db->fetchAll(
"SELECT day_of_week, work_mode FROM user_schedule_days
WHERE user_id = ? AND effective_to IS NULL AND work_mode != 'off'",
[$userId]
);
$workingDaysOfWeek = array_map(fn($s) => (int)$s['day_of_week'], $schedule);
// Get holidays in this month
$holidays = $this->db->fetchAll(
"SELECT start_date, end_date FROM holidays WHERE start_date <= ? AND end_date >= ?",
["{$month}-{$daysInMonth}", "{$month}-01"]
);
$holidayDates = [];
foreach ($holidays as $h) {
$start = new \DateTime($h['start_date']);
$end = new \DateTime($h['end_date']);
while ($start <= $end) {
$holidayDates[] = $start->format('Y-m-d');
$start->modify('+1 day');
}
}
$count = 0;
for ($day = 1; $day <= $daysInMonth; $day++) {
$date = sprintf('%s-%02d', $month, $day);
$dayOfWeek = (int)date('w', strtotime($date)); // 0=Sun
if (in_array($dayOfWeek, $workingDaysOfWeek) && !in_array($date, $holidayDates)) {
$count++;
}
}
return $count;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Salary\Calculators;
use Engine\Calculation\CalculatorInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
final class LiveSalaryCalculator implements CalculatorInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function calculate(array $context): mixed
{
$userId = $context['user_id'];
$month = $context['month'] ?? date('Y-m');
$actualSalary = (float)($context['actual_salary'] ?? 0);
$bounties = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?",
[$userId, $month]
);
$deductions = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(COALESCE(final_amount, calculated_amount)), 0) FROM deductions
WHERE contractor_id = ? AND payroll_month = ? AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL",
[$userId, $month]
);
$posAdj = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM manual_adjustments
WHERE contractor_id = ? AND effective_month = ? AND type = 'positive' AND status = 'approved' AND deleted_at IS NULL",
[$userId, $month]
);
$negAdj = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM manual_adjustments
WHERE contractor_id = ? AND effective_month = ? AND type = 'negative' AND status = 'approved' AND deleted_at IS NULL",
[$userId, $month]
);
return round($actualSalary + $bounties + $posAdj - $deductions - $negAdj, 2);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
// Salary routes are served via the Dashboard HUD and API endpoints
// Additional salary-specific routes will be added in Phase 2+
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Search\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Search\SearchEngine;
final class SearchController
{
private SearchEngine $search;
public function __construct()
{
$this->search = Container::getInstance()->resolve(SearchEngine::class);
}
public function search(Request $request): Response
{
$query = trim($request->query('q', ''));
if (strlen($query) < 2) {
return Response::json(['results' => []]);
}
$user = $request->user();
$results = $this->search->search($query, $user);
return Response::json(['results' => $results, 'query' => $query]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->get('/api/search', Modules\Search\Controllers\SearchController::class, 'search')
->middleware([Middleware\AuthenticationMiddleware::class]);
\ No newline at end of file
RewriteEngine On
RewriteBase /
# Block direct access to sensitive dirs
RewriteRule ^(bootstrap|config|engine|modules|templates|storage|database|cli|cron)/ - [F,L]
# Serve existing files/dirs directly
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Route everything else through index.php
RewriteRule ^(.*)$ index.php [QSA,L]
# Security headers
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
<?php
declare(strict_types=1);
define('ROOT_PATH', dirname(__DIR__));
define('START_TIME', microtime(true));
require ROOT_PATH . '/bootstrap/autoload.php';
require ROOT_PATH . '/bootstrap/app.php';
$app = Engine\Core\App::getInstance();
$app->run();
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<!DOCTYPE html>
<html><head><title>404 Not Found</title><link rel="stylesheet" href="/assets/css/app.css"></head>
<body class="error-page"><div class="error-container"><h1>404</h1><p>Page not found.</p><a href="/dashboard" class="btn btn-primary">Go Home</a></div></body></html>
\ No newline at end of file
<!DOCTYPE html>
<html><head><title>500 Server Error</title><link rel="stylesheet" href="/assets/css/app.css"></head>
<body class="error-page"><div class="error-container"><h1>500</h1><p>Internal server error.</p><a href="/dashboard" class="btn btn-primary">Go Home</a></div></body></html>
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment