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
<?php
declare(strict_types=1);
namespace Modules\Dashboard\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Template\TemplateEngine;
use Engine\Notifications\NotificationManager;
final class DashboardController
{
private Connection $db;
private TemplateEngine $templates;
private NotificationManager $notifications;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->templates = $c->resolve(TemplateEngine::class);
$this->notifications = $c->resolve(NotificationManager::class);
}
public function index(Request $request): Response
{
$user = $request->user();
// Route to role-specific dashboard
$template = match ($user['role']) {
'super_admin' => 'dashboard/super_admin',
'admin' => 'dashboard/admin',
'project_leader' => 'dashboard/project_leader',
'contractor' => 'dashboard/contractor',
};
$data = $this->getDashboardData($user);
$data['user'] = $user;
$data['notifications'] = $this->notifications->getRecent($user['id'], 5);
$data['unread_count'] = $this->notifications->getUnreadCount($user['id']);
return Response::html($this->templates->render($template, $data));
}
private function getDashboardData(array $user): array
{
$data = [];
$month = date('Y-m');
$today = date('Y-m-d');
if ($user['role'] === 'contractor') {
$userId = $user['id'];
// Today's report status
$todayReport = $this->db->fetchOne(
"SELECT * FROM daily_reports WHERE user_id = ? AND report_date = ?",
[$userId, $today]
);
$data['today_report'] = $todayReport;
// My tasks
$data['my_tasks'] = $this->db->fetchAll(
"SELECT c.*, bc.slug as column_slug, bc.name as column_name, b.name as board_name
FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id
JOIN board_columns bc ON bc.id = c.column_id
JOIN boards b ON b.id = c.board_id
WHERE ca.user_id = ? AND c.is_archived = 0
ORDER BY FIELD(bc.slug, 'doing', 'todo', 'in_review', 'frozen', 'backlog'), c.position_in_column",
[$userId]
);
// Upcoming deadlines
$data['upcoming_deadlines'] = $this->db->fetchAll(
"SELECT c.*, b.name as board_name FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id
JOIN boards b ON b.id = c.board_id
WHERE ca.user_id = ? AND c.deadline IS NOT NULL AND c.deadline >= NOW() AND c.is_archived = 0
AND c.done_at IS NULL
ORDER BY c.deadline ASC LIMIT 10",
[$userId]
);
// Month stats
$data['reports_submitted'] = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM daily_reports WHERE user_id = ? AND report_date LIKE ?",
[$userId, $month . '%']
);
// HUD data
$data['hud'] = $this->getHudData($user);
// Active learning goals
$data['learning_goals'] = $this->db->fetchAll(
"SELECT lg.*, ca.name as competency_name FROM learning_goals lg
JOIN competency_areas ca ON ca.id = lg.competency_area_id
WHERE lg.contractor_id = ? AND lg.status IN ('active','overdue') AND lg.deleted_at IS NULL
ORDER BY lg.deadline ASC LIMIT 5",
[$userId]
);
} elseif ($user['role'] === 'project_leader') {
// Team report status
$data['team_members'] = $this->db->fetchAll(
"SELECT DISTINCT u.id, u.full_name_en, u.username, u.profile_photo_id,
(SELECT status FROM daily_reports WHERE user_id = u.id AND report_date = ?) as report_status
FROM users u
JOIN board_members bm ON bm.user_id = u.id
JOIN board_members my_bm ON my_bm.board_id = bm.board_id AND my_bm.user_id = ?
WHERE u.role = 'contractor' AND u.status = 'active' AND u.id != ?",
[$today, $user['id'], $user['id']]
);
// Pending reviews
$data['pending_reviews'] = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM daily_reports dr
JOIN board_members bm ON bm.user_id = dr.user_id
JOIN board_members my_bm ON my_bm.board_id = bm.board_id AND my_bm.user_id = ?
WHERE dr.status IN ('submitted','late') AND dr.reviewed_by_id IS NULL",
[$user['id']]
);
// At-risk tasks
$data['at_risk_tasks'] = $this->db->fetchAll(
"SELECT c.*, 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 = ? AND bm.role_on_board = 'project_leader'
WHERE c.deadline IS NOT NULL AND c.deadline <= DATE_ADD(NOW(), INTERVAL 2 DAY)
AND c.done_at IS NULL AND c.is_archived = 0
ORDER BY c.deadline ASC LIMIT 10",
[$user['id']]
);
} elseif (in_array($user['role'], ['admin', 'super_admin'])) {
// Active contractors count
$data['active_contractors'] = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM users WHERE role = 'contractor' AND status = 'active'"
);
// Onboarding pipeline
$data['onboarding_count'] = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM users WHERE status = 'onboarding'"
);
// This month deductions
$data['month_deductions'] = $this->db->fetchOne(
"SELECT COUNT(*) as cnt, COALESCE(SUM(COALESCE(final_amount, calculated_amount)), 0) as total
FROM deductions WHERE payroll_month = ? AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL",
[$month]
);
// This month bounties
$data['month_bounties'] = $this->db->fetchOne(
"SELECT COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total FROM bounty_payouts WHERE payroll_month = ?",
[$month]
);
// Pending payroll
$data['payroll_status'] = $this->db->fetchOne(
"SELECT status, COUNT(*) as cnt FROM payroll_records WHERE month = ? GROUP BY status ORDER BY cnt DESC LIMIT 1",
[$month]
);
// Active PIPs
$data['active_pips'] = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM pips WHERE status IN ('created','acknowledged','active') AND deleted_at IS NULL"
);
if ($user['role'] === 'super_admin') {
// Total payroll this month
$data['total_payroll'] = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(net_payable), 0) FROM payroll_records WHERE month = ? AND status != 'rejected'",
[$month]
);
// Contract expirations
$data['expiring_contracts'] = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM users WHERE contract_end_date IS NOT NULL AND contract_end_date <= DATE_ADD(NOW(), INTERVAL 90 DAY) AND status = 'active'"
);
}
}
return $data;
}
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;
$retentionPct = $actualSalary > 0 ? ($liveSalary / $actualSalary) * 100 : 100;
$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]
);
// Health status
if ($deductionCount <= 1 && $retentionPct >= 80) {
$health = ['status' => 'healthy', 'icon' => '🟢', 'label' => 'Healthy'];
} elseif ($deductionCount <= 3 && $retentionPct >= 60) {
$health = ['status' => 'warning', 'icon' => '🟡', 'label' => 'Warning'];
} else {
$health = ['status' => 'critical', 'icon' => '🔴', 'label' => 'Critical'];
}
// Color zone
if ($retentionPct > 100) {
$colorClass = 'hud-exceptional';
} elseif ($retentionPct >= 80) {
$colorClass = 'hud-healthy';
} elseif ($retentionPct >= 60) {
$colorClass = 'hud-warning';
} else {
$colorClass = 'hud-critical';
}
// Streak calculation (simplified)
$streak = 0; // Full streak calculation would query report history
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,
'retention_pct' => $retentionPct,
'health' => $health,
'color_class' => $colorClass,
'streak' => $streak,
'month' => $month,
'month_label' => date('F Y'),
];
}
}
\ 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' => '',
'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
/* ======================================================
AL-ARCADE HR PLATFORM v3.0 — "THE GRIND"
Core Stylesheet — Phase 1
====================================================== */
:root {
--primary: #6366F1;
--primary-dark: #4F46E5;
--success: #22C55E;
--warning: #EAB308;
--danger: #EF4444;
--gold: #F59E0B;
--bg: #F8FAFC;
--bg-card: #FFFFFF;
--text: #1E293B;
--text-muted: #64748B;
--border: #E2E8F0;
--shadow: 0 1px 3px rgba(0,0,0,0.1);
--radius: 8px;
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
[data-theme="dark"] {
--bg: #0F172A;
--bg-card: #1E293B;
--text: #E2E8F0;
--text-muted: #94A3B8;
--border: #334155;
--shadow: 0 1px 3px rgba(0,0,0,0.3);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font); background: var(--bg); color: var(--text); line-height: 1.6; }
/* Navigation */
.top-nav { display: flex; align-items: center; gap: 16px; padding: 8px 24px; background: var(--bg-card); border-bottom: 1px solid var(--border); box-shadow: var(--shadow); position: sticky; top: 0; z-index: 100; }
.nav-brand a { text-decoration: none; font-size: 1.2em; font-weight: 700; color: var(--primary); }
.nav-search { flex: 1; max-width: 400px; }
.nav-search input { width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 0.9em; }
.nav-actions { display: flex; align-items: center; gap: 12px; margin-left: auto; }
.nav-btn { background: none; border: none; cursor: pointer; font-size: 1.1em; padding: 6px; border-radius: var(--radius); color: var(--text); position: relative; }
.nav-btn:hover { background: var(--bg); }
.nav-user { display: flex; flex-direction: column; align-items: end; }
.nav-user span:first-child { font-weight: 600; font-size: 0.9em; }
.role-badge { font-size: 0.7em; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.badge { position: absolute; top: -2px; right: -2px; background: var(--danger); color: white; border-radius: 50%; width: 18px; height: 18px; font-size: 0.65em; display: flex; align-items: center; justify-content: center; }
/* HUD */
.hud { padding: 12px 24px; background: var(--bg-card); border-bottom: 2px solid var(--border); }
.hud-primary { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.hud-month { font-weight: 700; }
.hud-bar-container { flex: 1; min-width: 200px; height: 24px; background: var(--border); border-radius: 12px; overflow: hidden; }
.hud-bar { height: 100%; border-radius: 12px; transition: width 0.5s ease, background 0.5s ease; }
.hud-exceptional .hud-bar, [data-color="hud-exceptional"] .hud-bar { background: linear-gradient(90deg, var(--gold), #FBBF24); }
.hud-healthy .hud-bar, [data-color="hud-healthy"] .hud-bar { background: var(--success); }
.hud-warning .hud-bar, [data-color="hud-warning"] .hud-bar { background: var(--warning); }
.hud-critical .hud-bar, [data-color="hud-critical"] .hud-bar { background: var(--danger); }
.hud-amount { font-weight: 700; font-size: 1.1em; white-space: nowrap; }
.hud-secondary { display: flex; gap: 16px; margin-top: 4px; font-size: 0.85em; color: var(--text-muted); }
.hud-deductions { color: var(--danger); }
.hud-bounties { color: var(--success); }
/* Main Content */
.main-content { padding: 24px; max-width: 1400px; margin: 0 auto; }
.container { max-width: 1200px; margin: 0 auto; }
/* Cards */
.card { background: var(--bg-card); border-radius: var(--radius); border: 1px solid var(--border); padding: 20px; box-shadow: var(--shadow); }
.card h3 { margin-bottom: 12px; font-size: 1em; color: var(--text-muted); }
.card-wide { grid-column: span 2; }
/* Dashboard Grid */
.dashboard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; margin-top: 20px; }
.stat-card { text-align: center; }
.stat-number { font-size: 2.5em; font-weight: 800; color: var(--primary); }
.stat-sub { font-size: 0.85em; color: var(--text-muted); }
/* Forms */
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-weight: 600; margin-bottom: 4px; font-size: 0.9em; }
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 0.95em; }
.form-group textarea { min-height: 100px; resize: vertical; }
/* Buttons */
.btn { display: inline-block; padding: 10px 20px; border-radius: var(--radius); font-weight: 600; text-decoration: none; cursor: pointer; border: none; font-size: 0.9em; transition: all 0.2s; }
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-secondary { background: var(--border); color: var(--text); }
.btn-danger { background: var(--danger); color: white; }
.btn-success { background: var(--success); color: white; }
.btn-sm { padding: 6px 12px; font-size: 0.8em; }
.btn-lg { padding: 14px 28px; font-size: 1.1em; }
.btn-block { display: block; width: 100%; text-align: center; }
.btn-link { background: none; color: var(--primary); padding: 0; }
/* Alerts */
.alert { padding: 12px 16px; border-radius: var(--radius); margin-bottom: 16px; font-size: 0.9em; }
.alert-error { background: #FEF2F2; color: #991B1B; border: 1px solid #FECACA; }
.alert-success { background: #F0FDF4; color: #166534; border: 1px solid #BBF7D0; }
.alert-warning { background: #FFFBEB; color: #92400E; border: 1px solid #FDE68A; }
/* Auth Page */
.auth-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.auth-container { width: 100%; max-width: 420px; padding: 20px; }
.auth-logo { text-align: center; font-size: 2em; font-weight: 800; margin-bottom: 30px; color: var(--primary); }
.auth-form { background: var(--bg-card); padding: 30px; border-radius: var(--radius); box-shadow: var(--shadow); border: 1px solid var(--border); }
.auth-form h2 { text-align: center; margin-bottom: 24px; }
/* Blocking Notification */
.blocking-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: rgba(0,0,0,0.85); }
.blocking-overlay { width: 100%; max-width: 600px; padding: 20px; }
.blocking-card { background: var(--bg-card); padding: 40px; border-radius: var(--radius); text-align: center; }
.blocking-icon { font-size: 3em; margin-bottom: 16px; }
.blocking-content { text-align: left; margin: 20px 0; padding: 16px; background: var(--bg); border-radius: var(--radius); }
.blocking-note { font-size: 0.85em; color: var(--text-muted); margin: 16px 0; font-style: italic; }
/* Notifications */
.notification-list { display: flex; flex-direction: column; gap: 8px; }
.notification-item { padding: 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); }
.notification-item.unread { border-left: 3px solid var(--primary); background: #F0F4FF; }
.notification-item.tier-blocking { border-left-color: var(--danger); }
.notif-header { display: flex; justify-content: space-between; margin-bottom: 4px; }
.notif-time { font-size: 0.8em; color: var(--text-muted); }
/* Tasks */
.task-list { display: flex; flex-direction: column; gap: 8px; }
.task-item { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 0.9em; }
.task-key { font-weight: 700; color: var(--primary); font-size: 0.8em; white-space: nowrap; }
.task-title { flex: 1; }
.task-status { padding: 2px 8px; border-radius: 12px; font-size: 0.75em; font-weight: 600; }
.overdue { color: var(--danger); font-weight: 600; }
/* Badges */
.badge-doing { background: #DBEAFE; color: #1E40AF; }
.badge-todo { background: #FEF9C3; color: #854D0E; }
.badge-in_review { background: #E0E7FF; color: #4338CA; }
.badge-frozen { background: #E0F2FE; color: #075985; }
.badge-done { background: #DCFCE7; color: #166534; }
.badge-backlog { background: var(--border); color: var(--text-muted); }
.badge-success { background: #DCFCE7; color: #166534; }
.badge-warning { background: #FEF9C3; color: #854D0E; }
.badge-danger { background: #FEF2F2; color: #991B1B; }
/* Page Header */
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
/* Team Status */
.team-member-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); }
/* Toast */
#toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; }
.toast { padding: 12px 20px; border-radius: var(--radius); color: white; font-weight: 600; animation: slideIn 0.3s ease; box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
.toast-success { background: var(--success); }
.toast-error { background: var(--danger); }
.toast-warning { background: var(--warning); color: #000; }
.toast-info { background: var(--primary); }
.toast-gold { background: linear-gradient(135deg, #F59E0B, #D97706); }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
/* Error Pages */
.error-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.error-container { text-align: center; }
.error-container h1 { font-size: 6em; font-weight: 800; color: var(--primary); }
/* Responsive */
@media (max-width: 768px) {
.top-nav { flex-wrap: wrap; gap: 8px; padding: 8px 12px; }
.nav-search { order: 3; max-width: 100%; flex-basis: 100%; }
.dashboard-grid { grid-template-columns: 1fr; }
.card-wide { grid-column: span 1; }
.hud { padding: 8px 12px; }
.main-content { padding: 12px; }
}
\ No newline at end of file
/**
* AL-ARCADE HR PLATFORM v3.0 — Core JavaScript
* Phase 1: SSE, Notifications, Search, Toast, CSRF
*/
(function() {
'use strict';
// ========== CSRF Token ==========
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.content : (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
}
// ========== Fetch Helper ==========
async function apiFetch(url, options = {}) {
const defaults = {
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
},
};
const config = { ...defaults, ...options };
if (options.headers) config.headers = { ...defaults.headers, ...options.headers };
const response = await fetch(url, config);
return response.json();
}
// ========== Toast System ==========
window.showToast = function(message, type = 'info', duration = 5000) {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
setTimeout(() => toast.remove(), 300);
}, duration);
};
// ========== SSE Connection ==========
function connectSSE() {
if (typeof EventSource === 'undefined') return;
const evtSource = new EventSource('/sse/stream');
evtSource.addEventListener('notification_count', function(e) {
try {
const data = JSON.parse(e.data);
const badge = document.getElementById('notif-count');
if (badge) {
if (data.unread_count > 0) {
badge.textContent = data.unread_count;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
}
} catch(err) {}
});
evtSource.addEventListener('hud_update', function(e) {
try {
const data = JSON.parse(e.data);
const hudAmount = document.querySelector('.hud-amount');
if (hudAmount) {
hudAmount.textContent = `EGP ${Math.round(data.live_salary).toLocaleString()} / ${Math.round(data.actual_salary).toLocaleString()}`;
}
const bar = document.querySelector('.hud-bar');
if (bar && data.actual_salary > 0) {
const pct = Math.min(100, Math.max(0, (data.live_salary / data.actual_salary) * 100));
bar.style.width = pct + '%';
}
} catch(err) {}
});
evtSource.addEventListener('blocking_notification', function(e) {
window.location.href = '/notifications/blocking';
});
evtSource.addEventListener('toast', function(e) {
try {
const data = JSON.parse(e.data);
showToast(data.message, data.type, data.duration || 5000);
} catch(err) {}
});
evtSource.onerror = function() {
evtSource.close();
setTimeout(connectSSE, 5000);
};
}
// ========== Search (Ctrl+K) ==========
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
const searchInput = document.getElementById('global-search');
if (searchInput) searchInput.focus();
}
});
const searchInput = document.getElementById('global-search');
if (searchInput) {
let searchTimeout;
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
const q = this.value.trim();
if (q.length < 2) return;
searchTimeout = setTimeout(async () => {
const data = await apiFetch('/api/search?q=' + encodeURIComponent(q));
// Render search results dropdown
const modal = document.getElementById('search-modal');
if (modal && data.results) {
let html = '<div class="search-results">';
data.results.forEach(r => {
html += `<a href="${r.url}" class="search-result-item">
<span class="search-type">${r.type}</span>
<span class="search-title">${r.title}</span>
<span class="search-context">${r.context}</span>
</a>`;
});
if (data.results.length === 0) {
html += '<p class="text-muted" style="padding:12px">No results found.</p>';
}
html += '</div>';
modal.innerHTML = html;
modal.style.display = 'block';
}
}, 300);
});
searchInput.addEventListener('blur', function() {
setTimeout(() => {
const modal = document.getElementById('search-modal');
if (modal) modal.style.display = 'none';
}, 200);
});
}
// ========== Notification Bell ==========
const notifBell = document.getElementById('notif-bell');
if (notifBell) {
notifBell.addEventListener('click', async function() {
const dropdown = document.getElementById('notif-dropdown');
if (!dropdown) return;
if (dropdown.style.display === 'block') {
dropdown.style.display = 'none';
return;
}
const data = await apiFetch('/notifications/recent');
let html = '<div class="notif-dropdown-list">';
(data.notifications || []).forEach(n => {
html += `<div class="notif-dropdown-item ${n.is_read ? '' : 'unread'}">
<strong>${n.title}</strong>
<p>${n.content.substring(0, 80)}</p>
<small>${new Date(n.created_at).toLocaleString()}</small>
</div>`;
});
html += '<a href="/notifications" style="display:block;padding:8px;text-align:center">View All</a>';
html += '</div>';
dropdown.innerHTML = html;
dropdown.style.display = 'block';
dropdown.style.position = 'fixed';
dropdown.style.right = '80px';
dropdown.style.top = '50px';
dropdown.style.width = '350px';
dropdown.style.maxHeight = '400px';
dropdown.style.overflow = 'auto';
dropdown.style.background = 'var(--bg-card)';
dropdown.style.border = '1px solid var(--border)';
dropdown.style.borderRadius = 'var(--radius)';
dropdown.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)';
dropdown.style.zIndex = '200';
});
}
// ========== Initialize ==========
document.addEventListener('DOMContentLoaded', function() {
connectSSE();
// Load initial notification count
apiFetch('/notifications/recent').then(data => {
const badge = document.getElementById('notif-count');
if (badge && data.unread_count > 0) {
badge.textContent = data.unread_count;
badge.style.display = 'flex';
}
}).catch(() => {});
});
})();
\ No newline at end of file
<?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
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Change Password<?php $__engine->endSection(); ?>
<div class="container">
<div class="card">
<h2>Change Password</h2>
<?php if (!empty($forced)): ?>
<div class="alert alert-warning">You must change your password before continuing.</div>
<?php endif; ?>
<?php if (!empty($error)): ?>
<div class="alert alert-error"><?= $__engine->e($error) ?></div>
<?php endif; ?>
<form method="POST" action="/password/change">
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>">
<div class="form-group">
<label>Current Password</label>
<input type="password" name="current_password" required>
</div>
<div class="form-group">
<label>New Password (min 10 chars, 1 upper, 1 lower, 1 number, 1 special)</label>
<input type="password" name="new_password" required minlength="10">
</div>
<div class="form-group">
<label>Confirm New Password</label>
<input type="password" name="confirm_password" required>
</div>
<button type="submit" class="btn btn-primary">Change Password</button>
</form>
</div>
</div>
\ No newline at end of file
<?php $__engine->extend('layouts/auth'); ?>
<?php $__engine->section('title'); ?>Login<?php $__engine->endSection(); ?>
<?php if (!empty($error)): ?>
<div class="alert alert-error"><?= $__engine->e($error) ?></div>
<?php endif; ?>
<form method="POST" action="/login" class="auth-form">
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>">
<h2>Sign In</h2>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus autocomplete="username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary btn-block">Sign In</button>
</form>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Admin Dashboard<?php $__engine->endSection(); ?>
<div class="dashboard">
<h1>Admin Dashboard</h1>
<div class="dashboard-grid">
<div class="card stat-card">
<h3>👥 Active Contractors</h3>
<div class="stat-number"><?= $active_contractors ?? 0 ?></div>
</div>
<div class="card stat-card">
<h3>📋 Onboarding</h3>
<div class="stat-number"><?= $onboarding_count ?? 0 ?></div>
</div>
<div class="card stat-card">
<h3>⚠️ Deductions This Month</h3>
<div class="stat-number"><?= $month_deductions['cnt'] ?? 0 ?></div>
<div class="stat-sub">EGP <?= number_format((float)($month_deductions['total'] ?? 0), 0) ?></div>
</div>
<div class="card stat-card">
<h3>💰 Bounties This Month</h3>
<div class="stat-number"><?= $month_bounties['cnt'] ?? 0 ?></div>
<div class="stat-sub">EGP <?= number_format((float)($month_bounties['total'] ?? 0), 0) ?></div>
</div>
<div class="card stat-card">
<h3>📊 Active PIPs</h3>
<div class="stat-number"><?= $active_pips ?? 0 ?></div>
</div>
</div>
</div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Dashboard<?php $__engine->endSection(); ?>
<div class="dashboard">
<h1>Welcome, <?= $__engine->e($user['full_name_en']) ?>!</h1>
<div class="dashboard-grid">
<!-- Today's Report -->
<div class="card">
<h3>📋 Today's Report</h3>
<?php if ($today_report && in_array($today_report['status'], ['submitted','approved','approved_auto','late'])): ?>
<p class="text-success">✅ Report submitted for today.</p>
<?php else: ?>
<p class="text-warning">⏳ You haven't submitted today's report yet.</p>
<a href="/reports/submit" class="btn btn-primary">Submit Report</a>
<?php endif; ?>
</div>
<!-- My Tasks -->
<div class="card card-wide">
<h3>🎯 My Tasks (<?= count($my_tasks ?? []) ?>)</h3>
<?php if (empty($my_tasks)): ?>
<p class="text-muted">No tasks assigned.</p>
<?php else: ?>
<div class="task-list">
<?php foreach (array_slice($my_tasks, 0, 10) as $task): ?>
<div class="task-item">
<span class="task-key"><?= $__engine->e($task['card_key'] ?? '') ?></span>
<span class="task-title"><?= $__engine->e($task['title']) ?></span>
<span class="task-status badge-<?= $__engine->e($task['column_slug']) ?>"><?= $__engine->e($task['column_name']) ?></span>
<?php if ($task['deadline']): ?>
<span class="task-deadline <?= strtotime($task['deadline']) < time() ? 'overdue' : '' ?>">
<?= date('M j', strtotime($task['deadline'])) ?>
</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- Upcoming Deadlines -->
<div class="card">
<h3>⏰ Upcoming Deadlines</h3>
<?php if (empty($upcoming_deadlines)): ?>
<p class="text-muted">No upcoming deadlines.</p>
<?php else: ?>
<?php foreach (array_slice($upcoming_deadlines, 0, 5) as $task): ?>
<div class="deadline-item">
<strong><?= $__engine->e($task['card_key'] ?? '') ?></strong>: <?= $__engine->e($task['title']) ?>
<br><small>Due: <?= date('M j, Y', strtotime($task['deadline'])) ?> · <?= $__engine->e($task['board_name']) ?></small>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<!-- Month Stats -->
<div class="card">
<h3>📊 This Month</h3>
<div class="stat-row">
<span>Reports Submitted</span>
<strong><?= $reports_submitted ?? 0 ?></strong>
</div>
</div>
<!-- Learning Goals -->
<?php if (!empty($learning_goals)): ?>
<div class="card">
<h3>📚 Learning Goals</h3>
<?php foreach ($learning_goals as $goal): ?>
<div class="goal-item">
<strong><?= $__engine->e($goal['title']) ?></strong>
<br><small><?= $__engine->e($goal['competency_name']) ?> · Due: <?= date('M j', strtotime($goal['deadline'])) ?></small>
<span class="badge badge-<?= $goal['status'] ?>"><?= ucfirst($goal['status']) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Recent Notifications -->
<div class="card">
<h3>🔔 Recent Notifications</h3>
<?php foreach ($notifications ?? [] as $notif): ?>
<div class="notif-item <?= $notif['is_read'] ? '' : 'unread' ?>">
<strong><?= $__engine->e($notif['title']) ?></strong>
<p><?= $__engine->e(substr($notif['content'], 0, 100)) ?></p>
<small><?= date('M j, H:i', strtotime($notif['created_at'])) ?></small>
</div>
<?php endforeach; ?>
<a href="/notifications" class="btn btn-link">View All</a>
</div>
</div>
</div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Project Leader Dashboard<?php $__engine->endSection(); ?>
<div class="dashboard">
<h1>Team Dashboard</h1>
<div class="dashboard-grid">
<div class="card card-wide">
<h3>📊 Team Report Status — Today</h3>
<div class="team-status-list">
<?php foreach ($team_members ?? [] as $member): ?>
<div class="team-member-row">
<span class="member-name"><?= $__engine->e($member['full_name_en']) ?></span>
<?php if ($member['report_status'] && in_array($member['report_status'], ['submitted','approved','approved_auto'])): ?>
<span class="badge badge-success">✅ Reported</span>
<?php elseif ($member['report_status'] === 'draft'): ?>
<span class="badge badge-warning">📝 Draft</span>
<?php else: ?>
<span class="badge badge-danger">⏳ Pending</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="card stat-card">
<h3>📋 Pending Reviews</h3>
<div class="stat-number"><?= $pending_reviews ?? 0 ?></div>
<a href="/reports/review" class="btn btn-sm btn-primary">Review Now</a>
</div>
<div class="card card-wide">
<h3>⚠️ At-Risk Tasks</h3>
<?php foreach ($at_risk_tasks ?? [] as $task): ?>
<div class="task-item">
<span class="task-key"><?= $__engine->e($task['card_key'] ?? '') ?></span>
<span class="task-title"><?= $__engine->e($task['title']) ?></span>
<span class="overdue"><?= date('M j', strtotime($task['deadline'])) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Super Admin Dashboard<?php $__engine->endSection(); ?>
<div class="dashboard">
<h1>Super Admin Dashboard</h1>
<div class="dashboard-grid">
<div class="card stat-card">
<h3>👥 Active Contractors</h3>
<div class="stat-number"><?= $active_contractors ?? 0 ?></div>
</div>
<div class="card stat-card">
<h3>💵 Total Payroll</h3>
<div class="stat-number">EGP <?= number_format($total_payroll ?? 0, 0) ?></div>
</div>
<div class="card stat-card">
<h3>⚠️ Deductions</h3>
<div class="stat-number"><?= $month_deductions['cnt'] ?? 0 ?></div>
<div class="stat-sub">EGP <?= number_format((float)($month_deductions['total'] ?? 0), 0) ?></div>
</div>
<div class="card stat-card">
<h3>💰 Bounties</h3>
<div class="stat-number"><?= $month_bounties['cnt'] ?? 0 ?></div>
<div class="stat-sub">EGP <?= number_format((float)($month_bounties['total'] ?? 0), 0) ?></div>
</div>
<div class="card stat-card">
<h3>📋 Onboarding</h3>
<div class="stat-number"><?= $onboarding_count ?? 0 ?></div>
</div>
<div class="card stat-card">
<h3>📊 Active PIPs</h3>
<div class="stat-number"><?= $active_pips ?? 0 ?></div>
</div>
<div class="card stat-card">
<h3>📄 Expiring Contracts</h3>
<div class="stat-number"><?= $expiring_contracts ?? 0 ?></div>
<div class="stat-sub">within 90 days</div>
</div>
</div>
</div>
\ No newline at end of file
<!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
<!DOCTYPE html>
<html lang="en" data-theme="<?= $__engine->e($user['theme_preference'] ?? 'light') ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $__engine->yield('title', 'AL-ARCADE HR Platform') ?></title>
<?= $__engine->csrfMeta() ?>
<link rel="stylesheet" href="/assets/css/app.css">
<?= $__engine->yield('head') ?>
</head>
<body class="<?= $__engine->yield('body_class', '') ?>">
<?php if (isset($user) && $user): ?>
<nav class="top-nav">
<div class="nav-brand">
<a href="/dashboard">🎮 The Grind</a>
</div>
<div class="nav-search">
<input type="text" id="global-search" placeholder="Search... (Ctrl+K)" autocomplete="off">
</div>
<div class="nav-actions">
<button class="nav-btn" id="notif-bell" title="Notifications">
🔔 <span class="badge" id="notif-count" style="display:none">0</span>
</button>
<a href="/messages" class="nav-btn" title="Messages">💬</a>
<div class="nav-user">
<span><?= $__engine->e($user['full_name_en']) ?></span>
<span class="role-badge"><?= $__engine->e(ucfirst(str_replace('_', ' ', $user['role']))) ?></span>
</div>
<form action="/logout" method="POST" style="display:inline">
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>">
<button type="submit" class="nav-btn" title="Logout">🚪</button>
</form>
</div>
</nav>
<?php if (isset($user['role']) && $user['role'] === 'contractor' && in_array($user['status'] ?? '', ['active','on_pip','suspended']) && isset($hud)): ?>
<div class="hud" id="salary-hud" data-color="<?= $__engine->e($hud['color_class'] ?? 'hud-healthy') ?>">
<div class="hud-primary">
<span class="hud-month">💰 <?= $__engine->e($hud['month_label'] ?? date('F Y')) ?></span>
<div class="hud-bar-container">
<div class="hud-bar" style="width: <?= min(100, max(0, $hud['retention_pct'] ?? 100)) ?>%"></div>
</div>
<span class="hud-amount">
EGP <?= number_format($hud['live_salary'] ?? 0, 0) ?> / <?= number_format($hud['actual_salary'] ?? 0, 0) ?>
</span>
</div>
<div class="hud-secondary">
<?php if (($hud['deduction_count'] ?? 0) > 0): ?>
<span class="hud-deductions"><?= $hud['deduction_count'] ?> deductions (-<?= number_format($hud['total_deductions'] ?? 0, 0) ?>)</span>
<?php endif; ?>
<?php if (($hud['bounty_count'] ?? 0) > 0): ?>
<span class="hud-bounties"><?= $hud['bounty_count'] ?> bounties (+<?= number_format($hud['total_bounties'] ?? 0, 0) ?>)</span>
<?php endif; ?>
<span class="hud-health"><?= $hud['health']['icon'] ?? '🟢' ?> <?= $hud['health']['label'] ?? 'Healthy' ?></span>
</div>
</div>
<?php endif; ?>
<main class="main-content">
<?= $__engine->content() ?>
</main>
<?php else: ?>
<?= $__engine->content() ?>
<?php endif; ?>
<div id="toast-container"></div>
<div id="search-modal" class="modal" style="display:none"></div>
<div id="notif-dropdown" class="dropdown" style="display:none"></div>
<script src="/assets/js/app.js"></script>
<?= $__engine->yield('scripts') ?>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $__engine->yield('title', 'AL-ARCADE HR Platform - Login') ?></title>
<link rel="stylesheet" href="/assets/css/app.css">
</head>
<body class="auth-page">
<div class="auth-container">
<div class="auth-logo">🎮 The Grind</div>
<?= $__engine->content() ?>
</div>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>⚠️ Action Required</title>
<link rel="stylesheet" href="/assets/css/app.css">
</head>
<body class="blocking-page">
<div class="blocking-overlay">
<div class="blocking-card">
<div class="blocking-icon">⚠️</div>
<h1><?= htmlspecialchars($notification['title'] ?? 'Action Required') ?></h1>
<div class="blocking-content">
<?= nl2br(htmlspecialchars($notification['content'] ?? '')) ?>
</div>
<form method="POST" action="/notifications/<?= (int)$notification['id'] ?>/acknowledge">
<input type="hidden" name="_csrf_token" value="<?= htmlspecialchars($_COOKIE['csrf_token'] ?? '') ?>">
<p class="blocking-note">Acknowledgment does not mean agreement. You are confirming you have seen this.</p>
<button type="submit" class="btn btn-primary btn-lg">I Acknowledge</button>
</form>
</div>
</div>
</body>
</html>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Notifications<?php $__engine->endSection(); ?>
<div class="container">
<div class="page-header">
<h1>🔔 Notifications</h1>
<form method="POST" action="/notifications/read-all">
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>">
<button type="submit" class="btn btn-sm btn-secondary">Mark All as Read</button>
</form>
</div>
<div class="notification-list">
<?php foreach ($notifications as $notif): ?>
<div class="notification-item <?= $notif['is_read'] ? 'read' : 'unread' ?> tier-<?= $__engine->e($notif['tier']) ?>">
<div class="notif-header">
<strong><?= $__engine->e($notif['title']) ?></strong>
<span class="notif-time"><?= date('M j, H:i', strtotime($notif['created_at'])) ?></span>
</div>
<p><?= $__engine->e($notif['content']) ?></p>
<?php if ($notif['link_url']): ?>
<a href="<?= $__engine->e($notif['link_url']) ?>" class="btn btn-sm btn-link">View →</a>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
\ No newline at end of file
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