Commit 96db1143 authored by Administrator's avatar Administrator

Update 84 files via Son of Anton

parent 223727b5
Pipeline #25 canceled with stage
<?php <?php
declare(strict_types=1); declare(strict_types=1);
use Engine\Core\App; require_once __DIR__ . '/autoload.php';
use Engine\Core\Config;
use Engine\Core\Container; use Engine\Core\{Container, Config, Router};
use Engine\Core\Router;
use Engine\Database\Connection; use Engine\Database\Connection;
use Engine\Auth\SessionManager; use Engine\Auth\{SessionManager, Authenticator, PasswordHasher, PermissionEngine, RateLimiter};
use Engine\Auth\Authenticator;
use Engine\Auth\PasswordHasher;
use Engine\Auth\PermissionEngine;
use Engine\Auth\RateLimiter;
use Engine\Audit\AuditLogger; use Engine\Audit\AuditLogger;
use Engine\Events\EventDispatcher;
use Engine\StateMachine\StateMachine;
use Engine\Notifications\NotificationManager; use Engine\Notifications\NotificationManager;
use Engine\Calculation\CalculationEngine; use Engine\Events\EventDispatcher;
use Engine\Validation\Validator; use Engine\Validation\Validator;
use Engine\FileStorage\FileManager; use Engine\Calculation\CalculationEngine;
use Engine\Scheduler\JobRunner; use Engine\Template\TemplateEngine;
use Engine\Search\SearchEngine; use Engine\Search\SearchEngine;
use Engine\FileStorage\FileManager;
use Engine\Export\ExportManager; use Engine\Export\ExportManager;
use Engine\Template\TemplateEngine; use Engine\Cache\QueryCache;
use Engine\Scheduler\JobRunner;
$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 // Timezone
$container->singleton(RateLimiter::class, function () use ($container) { date_default_timezone_set('Africa/Cairo');
return new RateLimiter($container->resolve(Connection::class));
});
// Authenticator // Error handling
$container->singleton(Authenticator::class, function () use ($container) { set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) {
return new Authenticator( throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
$container->resolve(Connection::class),
$container->resolve(PasswordHasher::class),
$container->resolve(SessionManager::class),
$container->resolve(RateLimiter::class)
);
}); });
// Permission Engine set_exception_handler(function (\Throwable $e) {
$container->singleton(PermissionEngine::class, function () use ($container) { error_log("UNCAUGHT: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}");
$perms = $container->resolve(Config::class)->get('permissions'); if (!headers_sent()) {
return new PermissionEngine($perms, $container->resolve(Connection::class)); http_response_code(500);
header('Content-Type: text/html');
}
$errorPage = ROOT_PATH . '/templates/errors/500.php';
if (file_exists($errorPage)) { require $errorPage; } else { echo '<h1>500 Internal Server Error</h1>'; }
exit(1);
}); });
// Audit Logger // ─── CONTAINER REGISTRATION ───
$container->singleton(AuditLogger::class, function () use ($container) { $container = Container::getInstance();
$cfg = $container->resolve(Config::class)->get('database');
return new AuditLogger($cfg);
});
// Event Dispatcher $container->singleton(Config::class, fn() => new Config());
$container->singleton(Connection::class, fn() => new Connection());
$container->singleton(SessionManager::class, fn() => new SessionManager());
$container->singleton(PasswordHasher::class, fn() => new PasswordHasher());
$container->singleton(RateLimiter::class, fn() => new RateLimiter());
$container->singleton(Authenticator::class, fn() => new Authenticator());
$container->singleton(PermissionEngine::class, fn() => new PermissionEngine());
$container->singleton(AuditLogger::class, fn() => new AuditLogger());
$container->singleton(NotificationManager::class, fn() => new NotificationManager());
$container->singleton(EventDispatcher::class, fn() => new EventDispatcher()); $container->singleton(EventDispatcher::class, fn() => new EventDispatcher());
$container->singleton(Validator::class, fn() => new Validator());
// State Machine $container->singleton(TemplateEngine::class, fn() => new TemplateEngine());
$container->singleton(StateMachine::class, function () use ($container) { $container->singleton(SearchEngine::class, fn() => new SearchEngine());
return new StateMachine($container->resolve(AuditLogger::class)); $container->singleton(FileManager::class, fn() => new FileManager());
}); $container->singleton(ExportManager::class, fn() => new ExportManager());
$container->singleton(QueryCache::class, fn() => new QueryCache());
// Notification Manager $container->singleton(JobRunner::class, fn() => new JobRunner());
$container->singleton(NotificationManager::class, function () use ($container) { $container->singleton(Router::class, fn() => new Router());
return new NotificationManager($container->resolve(Connection::class));
}); // ─── CALCULATION ENGINE ───
$calcEngine = new CalculationEngine();
// Calculation Engine $calculators = require ROOT_PATH . '/config/calculators.php';
$container->singleton(CalculationEngine::class, fn() => new CalculationEngine()); foreach ($calculators as $name => $class) {
$calcEngine->register($name, $class);
// 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 — use the static singleton so route files can call Router::get() etc.
$container->singleton(Router::class, fn() => Router::getInstance());
// 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;
if (is_array($listeners)) {
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);
});
}
}
}
} }
$container->instance(CalculationEngine::class, $calcEngine);
// ─── Register Calculators (silently skip if classes don't exist) ─── // ─── LOAD ALL MODULE ROUTES ───
$calcEngine = $container->resolve(CalculationEngine::class); $routeFiles = glob(ROOT_PATH . '/modules/*/routes.php');
$calculatorsFile = ROOT_PATH . '/config/calculators.php'; foreach ($routeFiles as $routeFile) {
if (file_exists($calculatorsFile)) { require_once $routeFile;
$calcs = require $calculatorsFile;
foreach ($calcs as $name => $className) {
try {
if (class_exists($className)) {
$calcEngine->register($name, $container->resolve($className));
}
} catch (\Throwable $e) {
error_log("[Bootstrap] Failed to register calculator '{$name}': {$e->getMessage()}");
}
}
}
// ─── Register Scheduled Jobs (silently skip if classes don't exist) ───
$jobRunner = $container->resolve(JobRunner::class);
$jobsFile = ROOT_PATH . '/config/scheduled_jobs.php';
if (file_exists($jobsFile)) {
$jobDefs = require $jobsFile;
foreach ($jobDefs as $key => $className) {
try {
if (class_exists($className)) {
$jobRunner->register($key, $container->resolve($className));
}
} catch (\Throwable $e) {
error_log("[Bootstrap] Failed to register job '{$key}': {$e->getMessage()}");
}
}
} }
// ─── Load All Module Routes ─── return $container;
$router = $container->resolve(Router::class); \ No newline at end of file
$moduleDir = ROOT_PATH . '/modules';
if (is_dir($moduleDir)) {
$modules = array_filter(glob($moduleDir . '/*'), 'is_dir');
sort($modules);
foreach ($modules as $modulePath) {
$routeFile = $modulePath . '/routes.php';
if (file_exists($routeFile)) {
try {
$result = require $routeFile;
// Handle route files that return a callable (BoardTemplates, CardTemplates pattern)
if (is_callable($result)) {
$result($router);
}
} catch (\Throwable $e) {
$moduleName = basename($modulePath);
error_log("[Bootstrap] Failed to load routes for module '{$moduleName}': {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}");
}
}
}
}
\ No newline at end of file
<?php <?php
declare(strict_types=1); declare(strict_types=1);
define('ROOT_PATH', dirname(__DIR__));
spl_autoload_register(function (string $class): void { spl_autoload_register(function (string $class): void {
// Namespace → directory mappings
$prefixes = [ $prefixes = [
'Engine\\' => ROOT_PATH . '/engine/', 'Engine\\' => ROOT_PATH . '/engine/',
'Middleware\\' => ROOT_PATH . '/middleware/',
'Modules\\' => ROOT_PATH . '/modules/', 'Modules\\' => ROOT_PATH . '/modules/',
'Middleware\\' => ROOT_PATH . '/middleware/',
]; ];
foreach ($prefixes as $prefix => $baseDir) { foreach ($prefixes as $prefix => $baseDir) {
$len = strlen($prefix); $len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) { if (strncmp($class, $prefix, $len) !== 0) {
continue; continue;
} }
$relativeClass = substr($class, $len); $relativeClass = substr($class, $len);
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php'; $file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) { if (file_exists($file)) {
require $file; require_once $file;
return; return;
} }
} }
......
<?php <?php
declare(strict_types=1); declare(strict_types=1);
define('ROOT_PATH', dirname(__DIR__)); require_once __DIR__ . '/../bootstrap/app.php';
require ROOT_PATH . '/bootstrap/autoload.php';
$dbConfig = require ROOT_PATH . '/config/database.php'; use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Auth\PasswordHasher;
try { $db = Container::getInstance()->resolve(Connection::class);
$dsn = "mysql:host={$dbConfig['host']};port={$dbConfig['port']};dbname={$dbConfig['database']};charset={$dbConfig['charset']}"; $hasher = Container::getInstance()->resolve(PasswordHasher::class);
$options = $dbConfig['options'] ?? [];
$options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false; $username = getenv('SA_USERNAME') ?: 'admin';
$pdo = new PDO($dsn, $dbConfig['username'], $dbConfig['password'], $options); $password = getenv('SA_PASSWORD') ?: 'Alarcade123#';
} catch (PDOException $e) { $nameEn = getenv('SA_NAME_EN') ?: 'Super Admin';
echo "DB Connection Failed: {$e->getMessage()}\n"; $nameAr = getenv('SA_NAME_AR') ?: 'مدير النظام';
exit(1);
}
// Check if super admin already exists $exists = $db->fetchOne("SELECT id FROM users WHERE username = ?", [$username]);
$stmt = $pdo->prepare("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1"); if ($exists) {
$stmt->execute(); echo "Super admin '{$username}' already exists (ID: {$exists['id']})\n";
if ($stmt->fetch()) {
echo "Super Admin already exists. Skipping.\n";
exit(0); exit(0);
} }
$username = 'admin'; $id = $db->insert('users', [
$password = 'Alarcade123#'; 'username' => $username,
$nameEn = 'Mahmoud Aglan'; 'password_hash' => $hasher->hash($password),
$nameAr = 'محمود عجلان'; 'role' => 'super_admin',
'full_name_en' => $nameEn,
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]); 'full_name_ar' => $nameAr,
'national_id' => '00000000000000',
$stmt = $pdo->prepare(" 'date_of_birth' => '1990-01-01',
INSERT INTO users ( 'phone_primary' => '+201000000000',
username, password_hash, role, full_name_en, full_name_ar, 'address' => 'System',
national_id, date_of_birth, phone_primary, address, 'emergency_contact_name' => 'N/A',
emergency_contact_name, emergency_contact_phone, emergency_contact_relationship, 'emergency_contact_phone' => '+201000000001',
bank_name, bank_account_number, bank_account_holder, 'emergency_contact_relationship' => 'other',
status, is_active, activation_date, force_password_change 'bank_name' => 'N/A',
) VALUES ( 'bank_account_number' => 'N/A',
?, ?, 'super_admin', ?, ?, 'bank_account_holder' => $nameEn,
'00000000000000', '1990-01-01', '01000000000', 'AL-Arcade HQ', 'status' => 'active',
'System', '01000000001', 'other', 'activation_date' => date('Y-m-d'),
'System', '0000000000', ?, 'is_active' => 1,
'active', 1, CURDATE(), 1 'force_password_change' => 0,
) ]);
");
echo "✅ Super admin created: {$username} (ID: {$id})\n";
try { \ No newline at end of file
$stmt->execute([$username, $hash, $nameEn, $nameAr, $nameEn]);
echo "✅ Super Admin created successfully.\n";
echo " Username: {$username}\n";
echo " Password: {$password}\n";
echo " ⚠️ Force password change is ON. Change on first login.\n";
} catch (PDOException $e) {
if (str_contains($e->getMessage(), 'Duplicate')) {
echo "Super Admin already exists (duplicate key).\n";
} else {
echo "ERROR: {$e->getMessage()}\n";
exit(1);
}
}
\ No newline at end of file
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** require_once __DIR__ . '/../bootstrap/app.php';
* Cron entry point. Add to crontab:
* * * * * * php /var/www/html/cron/runner.php >> /var/www/html/storage/logs/cron.log 2>&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\Container;
use Engine\Scheduler\JobRunner; use Engine\Scheduler\JobRunner;
$container = Container::getInstance(); $runner = Container::getInstance()->resolve(JobRunner::class);
$runner = $container->resolve(JobRunner::class); $count = $runner->runDue();
echo date('Y-m-d H:i:s') . " — Ran {$count} jobs.\n";
echo "[" . date('Y-m-d H:i:s') . "] Cron runner started.\n"; \ No newline at end of file
try {
$results = $runner->runDue();
foreach ($results as $key => $result) {
$status = $result['success'] ? '✅' : '❌';
echo " {$status} {$key}: {$result['message']}\n";
}
} catch (\Throwable $e) {
echo " ❌ Runner error: {$e->getMessage()}\n";
error_log("[Cron Error] {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}");
}
echo "[" . date('Y-m-d H:i:s') . "] Cron runner finished.\n";
\ No newline at end of file
...@@ -3,76 +3,40 @@ declare(strict_types=1); ...@@ -3,76 +3,40 @@ declare(strict_types=1);
namespace Engine\Audit; namespace Engine\Audit;
use PDO; use Engine\Core\Container;
use Engine\Database\Connection;
final class AuditLogger final class AuditLogger
{ {
private ?PDO $pdo = null; private Connection $db;
private array $config;
private array $sensitiveFields = ['password', 'password_hash', 'temp_password_hash', 'key_hash', 'secret'];
public function __construct(array $config) public function __construct()
{ {
$this->config = $config; $this->db = Container::getInstance()->resolve(Connection::class);
}
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,
PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => false,
]);
}
return $this->pdo;
} }
public function log( public function log(
?array $user, ?array $user, string $action, string $entityType, ?int $entityId,
string $action, string $module, string $endpoint, ?array $before = null, ?array $after = null,
string $entityType, ?string $ip = null, ?string $userAgent = null
?int $entityId,
string $module,
?string $endpoint = null,
?array $before = null,
?array $after = null,
?string $ip = null,
?string $userAgent = null
): void { ): void {
try { try {
$stmt = $this->pdo()->prepare( $this->db->insert('audit_trail', [
"INSERT INTO audit_trail (user_id, username, user_role, action, entity_type, entity_id, module, endpoint, before_json, after_json, ip_address, user_agent) 'user_id' => $user['id'] ?? null,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" 'username' => $user['username'] ?? null,
); 'user_role' => $user['role'] ?? null,
'action' => $action,
$stmt->execute([ 'entity_type' => $entityType,
$user['id'] ?? null, 'entity_id' => $entityId,
$user['username'] ?? null, 'module' => $module,
$user['role'] ?? null, 'endpoint' => $endpoint,
$action, 'before_json' => $before ? json_encode($before) : null,
$entityType, 'after_json' => $after ? json_encode($after) : null,
$entityId, 'ip_address' => $ip,
$module, 'user_agent' => $userAgent,
$endpoint,
$before ? json_encode($this->sanitize($before)) : null,
$after ? json_encode($this->sanitize($after)) : null,
$ip,
$userAgent,
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
error_log("[AuditLogger CRITICAL] Failed to log audit: {$e->getMessage()}"); error_log("AuditLogger failed: " . $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
...@@ -3,6 +3,7 @@ declare(strict_types=1); ...@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Engine\Auth; namespace Engine\Auth;
use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
final class Authenticator final class Authenticator
...@@ -10,29 +11,30 @@ final class Authenticator ...@@ -10,29 +11,30 @@ final class Authenticator
private Connection $db; private Connection $db;
private PasswordHasher $hasher; private PasswordHasher $hasher;
private SessionManager $sessions; private SessionManager $sessions;
private RateLimiter $rateLimiter; private RateLimiter $limiter;
public function __construct(Connection $db, PasswordHasher $hasher, SessionManager $sessions, RateLimiter $rateLimiter) public function __construct()
{ {
$this->db = $db; $c = Container::getInstance();
$this->hasher = $hasher; $this->db = $c->resolve(Connection::class);
$this->sessions = $sessions; $this->hasher = $c->resolve(PasswordHasher::class);
$this->rateLimiter = $rateLimiter; $this->sessions = $c->resolve(SessionManager::class);
$this->limiter = $c->resolve(RateLimiter::class);
} }
public function attempt(string $username, string $password, string $ip, string $userAgent): array public function attempt(string $username, string $password, string $ip, string $userAgent): array
{ {
// Check rate limit // Rate limiting
if ($this->rateLimiter->isLocked($username, $ip)) { if ($this->limiter->isLocked($username, $ip)) {
$this->logAttempt($username, $ip, $userAgent, false, 'locked'); $this->logAttempt($username, $ip, $userAgent, false, 'rate_limited');
return ['success' => false, 'error' => 'Account temporarily locked. Try again later.']; return ['success' => false, 'error' => 'Too many login attempts. Please wait 15 minutes.'];
} }
$user = $this->db->fetchOne('SELECT * FROM users WHERE username = ?', [$username]); $user = $this->db->fetchOne("SELECT * FROM users WHERE username = ?", [$username]);
if (!$user) { if (!$user) {
$this->logAttempt($username, $ip, $userAgent, false, 'user_not_found'); $this->logAttempt($username, $ip, $userAgent, false, 'user_not_found');
$this->rateLimiter->recordFailure($username, $ip); $this->limiter->recordFailure($username, $ip);
return ['success' => false, 'error' => 'Invalid username or password.']; return ['success' => false, 'error' => 'Invalid username or password.'];
} }
...@@ -41,67 +43,47 @@ final class Authenticator ...@@ -41,67 +43,47 @@ final class Authenticator
return ['success' => false, 'error' => 'Account is deactivated.']; return ['success' => false, 'error' => 'Account is deactivated.'];
} }
if ($user['status'] === 'terminated') { // Check temp password first
$this->logAttempt($username, $ip, $userAgent, false, 'terminated');
return ['success' => false, 'error' => 'Account has been terminated.'];
}
// Check temp password
$authenticated = false; $authenticated = false;
$usedTempPassword = false; if ($user['temp_password_hash'] && $user['temp_password_expires_at']) {
if (strtotime($user['temp_password_expires_at']) > time()) {
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'])) {
if ($this->hasher->verify($password, $user['temp_password_hash'])) { $authenticated = true;
$authenticated = true; }
$usedTempPassword = true;
} }
} }
if (!$authenticated && $this->hasher->verify($password, $user['password_hash'])) { if (!$authenticated && !$this->hasher->verify($password, $user['password_hash'])) {
$authenticated = true;
}
if (!$authenticated) {
$this->logAttempt($username, $ip, $userAgent, false, 'wrong_password'); $this->logAttempt($username, $ip, $userAgent, false, 'wrong_password');
$this->rateLimiter->recordFailure($username, $ip); $this->limiter->recordFailure($username, $ip);
return ['success' => false, 'error' => 'Invalid username or password.']; return ['success' => false, 'error' => 'Invalid username or password.'];
} }
// Success // Success
$this->logAttempt($username, $ip, $userAgent, true, null); $this->logAttempt($username, $ip, $userAgent, true, null);
$this->rateLimiter->clearFailures($username, $ip); $this->limiter->clearFailures($username, $ip);
$sessionToken = $this->sessions->create($user['id'], $ip, $userAgent); // Create session
$this->sessions->create($user['id'], $ip, $userAgent);
// Update last login // Update last login
$this->db->update('users', ['last_login_at' => date('Y-m-d H:i:s')], 'id = ?', [$user['id']]); $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 [ return [
'success' => true, 'success' => true,
'user_id' => $user['id'], 'user_id' => $user['id'],
'session_token' => $sessionToken, 'role' => $user['role'],
'force_password_change' => (bool)$user['force_password_change'] || $usedTempPassword, 'force_password_change' => (bool)$user['force_password_change'],
'role' => $user['role'],
'status' => $user['status'],
]; ];
} }
private function logAttempt(string $username, string $ip, string $userAgent, bool $success, ?string $reason): void private function logAttempt(string $username, string $ip, string $ua, bool $success, ?string $reason): void
{ {
$this->db->insert('login_attempts', [ $this->db->insert('login_attempts', [
'username' => $username, 'username' => $username,
'ip_address' => $ip, 'ip_address' => $ip,
'user_agent' => $userAgent, 'user_agent' => $ua,
'success' => $success ? 1 : 0, 'success' => $success ? 1 : 0,
'failure_reason' => $reason, 'failure_reason' => $reason,
]); ]);
} }
......
<?php
declare(strict_types=1);
namespace Engine\Auth;
class ForbiddenException extends \RuntimeException
{
public function __construct(string $message = 'Forbidden', int $code = 403)
{
parent::__construct($message, $code);
}
}
\ No newline at end of file
...@@ -5,12 +5,7 @@ namespace Engine\Auth; ...@@ -5,12 +5,7 @@ namespace Engine\Auth;
final class PasswordHasher final class PasswordHasher
{ {
private int $cost; private int $cost = 12;
public function __construct(int $cost = 12)
{
$this->cost = $cost;
}
public function hash(string $password): string public function hash(string $password): string
{ {
......
...@@ -3,104 +3,26 @@ declare(strict_types=1); ...@@ -3,104 +3,26 @@ declare(strict_types=1);
namespace Engine\Auth; namespace Engine\Auth;
use Engine\Database\Connection;
final class PermissionEngine final class PermissionEngine
{ {
private array $permissions; 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 public function __construct()
{ {
return (bool)$this->db->fetchOne( $this->permissions = require ROOT_PATH . '/config/permissions.php';
"SELECT 1 FROM card_assignments WHERE user_id = ? AND card_id = ?",
[$userId, $cardId]
);
} }
private function isOnSameBoard(int $userId, int $targetUserId): bool public function can(array $user, string $permission, array $context = []): bool
{ {
return (bool)$this->db->fetchOne( $role = $user['role'] ?? '';
"SELECT 1 FROM board_members bm1 $allowedRoles = $this->permissions[$permission] ?? [];
JOIN board_members bm2 ON bm1.board_id = bm2.board_id return in_array($role, $allowedRoles, true);
WHERE bm1.user_id = ? AND bm2.user_id = ?",
[$userId, $targetUserId]
);
} }
public function denyUnlessAllowed(array $user, string $action, array $context = []): void public function denyUnlessAllowed(array $user, string $permission, array $context = []): void
{ {
if (!$this->can($user, $action, $context)) { if (!$this->can($user, $permission, $context)) {
throw new \RuntimeException("Permission denied: {$action}", 403); throw new \Engine\Auth\ForbiddenException("Permission denied: {$permission}");
} }
} }
} }
\ No newline at end of file
...@@ -3,41 +3,28 @@ declare(strict_types=1); ...@@ -3,41 +3,28 @@ declare(strict_types=1);
namespace Engine\Auth; namespace Engine\Auth;
use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
final class RateLimiter final class RateLimiter
{ {
private Connection $db; private Connection $db;
private int $maxAttempts = 5; private int $maxAttempts = 5;
private int $lockoutSeconds = 1800; // 30 minutes private int $lockoutMinutes = 15;
private int $maxDailyAttempts = 15;
public function __construct(Connection $db) public function __construct()
{ {
$this->db = $db; $this->db = Container::getInstance()->resolve(Connection::class);
} }
public function isLocked(string $username, string $ip): bool public function isLocked(string $username, string $ip): bool
{ {
// Check recent failures for username $since = date('Y-m-d H:i:s', strtotime("-{$this->lockoutMinutes} minutes"));
$recentFailures = (int)$this->db->fetchColumn( $count = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM login_attempts "SELECT COUNT(*) FROM login_attempts WHERE (username = ? OR ip_address = ?) AND success = 0 AND created_at > ?",
WHERE username = ? AND success = 0 AND created_at > DATE_SUB(NOW(), INTERVAL ? SECOND)", [$username, $ip, $since]
[$username, $this->lockoutSeconds]
); );
return $count >= $this->maxAttempts;
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 public function recordFailure(string $username, string $ip): void
...@@ -47,6 +34,6 @@ final class RateLimiter ...@@ -47,6 +34,6 @@ final class RateLimiter
public function clearFailures(string $username, string $ip): void public function clearFailures(string $username, string $ip): void
{ {
// No need to delete - the window-based check handles it // Failures are kept for audit; rate limiting uses time window
} }
} }
\ No newline at end of file
...@@ -3,120 +3,118 @@ declare(strict_types=1); ...@@ -3,120 +3,118 @@ declare(strict_types=1);
namespace Engine\Auth; namespace Engine\Auth;
use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
final class SessionManager final class SessionManager
{ {
private Connection $db; private Connection $db;
private string $cookieName = 'al_arcade_session';
private int $lifetime = 28800; // 8 hours
public function __construct(Connection $db) public function __construct()
{ {
$this->db = $db; $this->db = Container::getInstance()->resolve(Connection::class);
}
public function start(): void
{
if (session_status() === PHP_SESSION_NONE) {
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_secure', '0');
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.gc_maxlifetime', '28800');
session_start();
}
} }
public function create(int $userId, string $ip, string $userAgent): string public function create(int $userId, string $ip, string $userAgent): string
{ {
$token = bin2hex(random_bytes(64)); $this->start();
$sessionId = session_id();
// Remove old session for this ID if exists
$this->db->delete('sessions', 'id = ?', [$sessionId]);
$this->db->insert('sessions', [ $this->db->insert('sessions', [
'id' => $token, 'id' => $sessionId,
'user_id' => $userId, 'user_id' => $userId,
'ip_address' => $ip, 'ip_address' => $ip,
'user_agent' => $userAgent, 'user_agent' => $userAgent,
'last_activity_at' => date('Y-m-d H:i:s'), 'last_activity_at' => date('Y-m-d H:i:s'),
]); ]);
setcookie($this->cookieName, $token, [ $_SESSION['user_id'] = $userId;
'expires' => time() + $this->lifetime, $_SESSION['session_id'] = $sessionId;
'path' => '/',
'httponly' => true,
'secure' => isset($_SERVER['HTTPS']),
'samesite' => 'Lax',
]);
return $token; return $sessionId;
} }
public function validate(): ?array public function validate(): ?array
{ {
$token = $_COOKIE[$this->cookieName] ?? null; $this->start();
if (!$token) return null;
if (empty($_SESSION['user_id'])) return null;
$userId = (int)$_SESSION['user_id'];
$sessionId = session_id();
$session = $this->db->fetchOne( $session = $this->db->fetchOne(
"SELECT s.*, u.id as uid, u.username, u.role, u.full_name_en, u.status, u.is_active, "SELECT s.*, u.* FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.user_id = ? AND u.is_active = 1",
u.contractor_type, u.assigned_pl_id, u.actual_salary, u.base_salary, [$sessionId, $userId]
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) { if (!$session) {
$this->destroyToken($token); $this->destroy();
return null; return null;
} }
if (!$session['is_active']) { // Update last activity (throttle to every 60 seconds)
$this->destroyToken($token); $lastActivity = strtotime($session['last_activity_at']);
if (time() - $lastActivity > 60) {
$this->db->update('sessions', ['last_activity_at' => date('Y-m-d H:i:s')], 'id = ?', [$sessionId]);
}
// Check session timeout (8 hours)
if (time() - $lastActivity > 28800) {
$this->destroy();
return null; return null;
} }
// Touch activity return $session;
$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 public function destroy(): void
{ {
$token = $_COOKIE[$this->cookieName] ?? null; $this->start();
if ($token) { $sessionId = session_id();
$this->destroyToken($token); $this->db->delete('sessions', 'id = ?', [$sessionId]);
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
} }
session_destroy();
} }
private function destroyToken(string $token): void public function destroyAllForUser(int $userId, ?string $exceptSessionId = null): 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) { if ($exceptSessionId) {
$this->db->query('DELETE FROM sessions WHERE user_id = ? AND id != ?', [$userId, $except]); $this->db->query("DELETE FROM sessions WHERE user_id = ? AND id != ?", [$userId, $exceptSessionId]);
} else { } else {
$this->db->delete('sessions', 'user_id = ?', [$userId]); $this->db->delete('sessions', 'user_id = ?', [$userId]);
} }
} }
public function listForUser(int $userId): array public function generateCsrfToken(): string
{ {
return $this->db->fetchAll( $this->start();
'SELECT id, ip_address, user_agent, last_activity_at, created_at FROM sessions WHERE user_id = ? ORDER BY last_activity_at DESC', if (empty($_SESSION['csrf_token'])) {
[$userId] $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
); }
return $_SESSION['csrf_token'];
} }
public function cleanup(): int public function verifyCsrfToken(string $token): bool
{ {
return $this->db->delete('sessions', 'last_activity_at < DATE_SUB(NOW(), INTERVAL ? SECOND)', [$this->lifetime]); return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
} }
} }
\ No newline at end of file
...@@ -5,90 +5,11 @@ namespace Engine\Cache; ...@@ -5,90 +5,11 @@ namespace Engine\Cache;
final class QueryCache final class QueryCache
{ {
private static array $cache = []; private array $cache = [];
private static int $hits = 0;
private static int $misses = 0;
private static int $maxEntries = 500;
private static int $defaultTtl = 60;
public static function get(string $key): mixed public function get(string $key): mixed { return $this->cache[$key] ?? null; }
{ public function set(string $key, mixed $value, int $ttl = 300): void { $this->cache[$key] = $value; }
if (isset(self::$cache[$key])) { public function has(string $key): bool { return isset($this->cache[$key]); }
$entry = self::$cache[$key]; public function forget(string $key): void { unset($this->cache[$key]); }
if ($entry['expires_at'] > time()) { public function flush(): void { $this->cache = []; }
self::$hits++;
return $entry['value'];
}
unset(self::$cache[$key]);
}
self::$misses++;
return null;
}
public static function set(string $key, mixed $value, int $ttl = 0): void
{
if ($ttl <= 0) $ttl = self::$defaultTtl;
if (count(self::$cache) >= self::$maxEntries) {
$oldest = null;
$oldestTime = PHP_INT_MAX;
foreach (self::$cache as $k => $entry) {
if ($entry['expires_at'] < $oldestTime) {
$oldestTime = $entry['expires_at'];
$oldest = $k;
}
}
if ($oldest !== null) unset(self::$cache[$oldest]);
}
self::$cache[$key] = [
'value' => $value,
'expires_at' => time() + $ttl,
];
}
public static function has(string $key): bool
{
return isset(self::$cache[$key]) && self::$cache[$key]['expires_at'] > time();
}
public static function forget(string $key): void
{
unset(self::$cache[$key]);
}
public static function forgetPattern(string $pattern): void
{
foreach (array_keys(self::$cache) as $key) {
if (fnmatch($pattern, $key)) {
unset(self::$cache[$key]);
}
}
}
public static function flush(): void
{
self::$cache = [];
}
public static function remember(string $key, callable $callback, int $ttl = 0): mixed
{
$cached = self::get($key);
if ($cached !== null) return $cached;
$value = $callback();
self::set($key, $value, $ttl);
return $value;
}
public static function stats(): array
{
return [
'entries' => count(self::$cache),
'hits' => self::$hits,
'misses' => self::$misses,
'hit_rate' => (self::$hits + self::$misses) > 0 ? round(self::$hits / (self::$hits + self::$misses) * 100, 1) : 0,
'max_entries' => self::$maxEntries,
];
}
} }
\ No newline at end of file
...@@ -7,21 +7,23 @@ final class CalculationEngine ...@@ -7,21 +7,23 @@ final class CalculationEngine
{ {
private array $calculators = []; private array $calculators = [];
public function register(string $name, CalculatorInterface $calculator): void public function register(string $name, string $class): void
{ {
$this->calculators[$name] = $calculator; $this->calculators[$name] = $class;
} }
public function calculate(string $name, array $context): mixed public function has(string $name): bool
{ {
if (!isset($this->calculators[$name])) { return isset($this->calculators[$name]);
throw new \RuntimeException("Calculator not registered: {$name}");
}
return $this->calculators[$name]->calculate($context);
} }
public function has(string $name): bool public function calculate(string $name, array $context = []): mixed
{ {
return isset($this->calculators[$name]); $class = $this->calculators[$name] ?? null;
if (!$class || !class_exists($class)) {
throw new \RuntimeException("Calculator [{$name}] not registered.");
}
$instance = new $class();
return $instance->calculate($context);
} }
} }
\ No newline at end of file
...@@ -5,29 +5,29 @@ namespace Engine\Core; ...@@ -5,29 +5,29 @@ namespace Engine\Core;
final class Config final class Config
{ {
private array $data = []; private array $items = [];
private string $configPath;
public function __construct(string $configPath) public function __construct()
{ {
$this->configPath = $configPath; $this->loadAll();
} }
public function get(string $key, mixed $default = null): mixed private function loadAll(): void
{ {
$parts = explode('.', $key); $configPath = ROOT_PATH . '/config';
$file = array_shift($parts); if (!is_dir($configPath)) return;
if (!isset($this->data[$file])) { foreach (glob($configPath . '/*.php') as $file) {
$path = $this->configPath . '/' . $file . '.php'; $key = basename($file, '.php');
if (file_exists($path)) { $this->items[$key] = require $file;
$this->data[$file] = require $path;
} else {
return $default;
}
} }
}
public function get(string $key, mixed $default = null): mixed
{
$parts = explode('.', $key);
$value = $this->items;
$value = $this->data[$file];
foreach ($parts as $part) { foreach ($parts as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) { if (!is_array($value) || !array_key_exists($part, $value)) {
return $default; return $default;
...@@ -38,16 +38,27 @@ final class Config ...@@ -38,16 +38,27 @@ final class Config
return $value; return $value;
} }
public function all(string $file): array public function set(string $key, mixed $value): void
{ {
if (!isset($this->data[$file])) { $parts = explode('.', $key);
$path = $this->configPath . '/' . $file . '.php'; $target = &$this->items;
if (file_exists($path)) {
$this->data[$file] = require $path; foreach ($parts as $i => $part) {
if ($i === count($parts) - 1) {
$target[$part] = $value;
} else { } else {
return []; if (!isset($target[$part]) || !is_array($target[$part])) {
$target[$part] = [];
}
$target = &$target[$part];
} }
} }
return $this->data[$file]; }
public function all(string $key = ''): array
{
if ($key === '') return $this->items;
$val = $this->get($key);
return is_array($val) ? $val : [];
} }
} }
\ No newline at end of file
...@@ -5,7 +5,7 @@ namespace Engine\Core; ...@@ -5,7 +5,7 @@ namespace Engine\Core;
final class Container final class Container
{ {
private static ?self $instance = null; private static ?Container $instance = null;
private array $bindings = []; private array $bindings = [];
private array $singletons = []; private array $singletons = [];
private array $resolved = []; private array $resolved = [];
...@@ -18,41 +18,48 @@ final class Container ...@@ -18,41 +18,48 @@ final class Container
return self::$instance; return self::$instance;
} }
public function bind(string $abstract, callable $factory): void public function singleton(string $abstract, callable|string|null $concrete = null): void
{ {
$this->bindings[$abstract] = ['factory' => $factory, 'singleton' => false]; $this->bindings[$abstract] = $concrete ?? $abstract;
$this->singletons[$abstract] = true;
} }
public function singleton(string $abstract, callable $factory): void public function bind(string $abstract, callable|string|null $concrete = null): void
{ {
$this->bindings[$abstract] = ['factory' => $factory, 'singleton' => true]; $this->bindings[$abstract] = $concrete ?? $abstract;
} }
public function resolve(string $abstract): mixed public function resolve(string $abstract): mixed
{ {
if (isset($this->resolved[$abstract])) { if (isset($this->resolved[$abstract]) && isset($this->singletons[$abstract])) {
return $this->resolved[$abstract]; return $this->resolved[$abstract];
} }
if (!isset($this->bindings[$abstract])) { $concrete = $this->bindings[$abstract] ?? $abstract;
if (class_exists($abstract)) {
return new $abstract();
}
throw new \RuntimeException("No binding found for: {$abstract}");
}
$binding = $this->bindings[$abstract]; if (is_callable($concrete)) {
$instance = ($binding['factory'])(); $object = $concrete($this);
} elseif (is_string($concrete) && class_exists($concrete)) {
$object = new $concrete();
} else {
throw new \RuntimeException("Cannot resolve [{$abstract}] from the container.");
}
if ($binding['singleton']) { if (isset($this->singletons[$abstract])) {
$this->resolved[$abstract] = $instance; $this->resolved[$abstract] = $object;
} }
return $instance; return $object;
} }
public function has(string $abstract): bool public function has(string $abstract): bool
{ {
return isset($this->bindings[$abstract]) || isset($this->resolved[$abstract]); return isset($this->bindings[$abstract]) || isset($this->resolved[$abstract]);
} }
public function instance(string $abstract, mixed $instance): void
{
$this->resolved[$abstract] = $instance;
$this->singletons[$abstract] = true;
}
} }
\ No newline at end of file
...@@ -5,15 +5,9 @@ namespace Engine\Core; ...@@ -5,15 +5,9 @@ namespace Engine\Core;
final class MiddlewarePipeline final class MiddlewarePipeline
{ {
private Container $container;
private Request $request; private Request $request;
private array $middleware = []; private array $middleware = [];
public function __construct(Container $container)
{
$this->container = $container;
}
public function send(Request $request): self public function send(Request $request): self
{ {
$this->request = $request; $this->request = $request;
...@@ -31,14 +25,12 @@ final class MiddlewarePipeline ...@@ -31,14 +25,12 @@ final class MiddlewarePipeline
$pipeline = array_reduce( $pipeline = array_reduce(
array_reverse($this->middleware), array_reverse($this->middleware),
function (callable $next, string $middlewareClass) { function (callable $next, string $middlewareClass) {
return function (Request $request) use ($middlewareClass, $next): Response { return function (Request $request) use ($next, $middlewareClass) {
$middleware = $this->container->resolve($middlewareClass); $instance = new $middlewareClass();
return $middleware->handle($request, $next); return $instance->handle($request, $next);
}; };
}, },
function (Request $request) use ($destination): Response { $destination
return $destination($request);
}
); );
return $pipeline($this->request); return $pipeline($this->request);
......
...@@ -5,107 +5,119 @@ namespace Engine\Core; ...@@ -5,107 +5,119 @@ namespace Engine\Core;
final class Request final class Request
{ {
private string $method;
private string $uri;
private array $query; private array $query;
private array $body; private array $post;
private array $files;
private array $server; private array $server;
private array $cookies;
private array $headers; private array $headers;
private array $cookies;
private ?array $jsonBody = null;
private ?array $user = null;
private array $attributes = []; private array $attributes = [];
private function __construct() public function __construct()
{ {
$this->method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); $this->query = $_GET;
$this->uri = $this->parseUri(); $this->post = $_POST;
$this->query = $_GET; $this->server = $_SERVER;
$this->body = $this->parseBody();
$this->files = $_FILES;
$this->server = $_SERVER;
$this->cookies = $_COOKIE; $this->cookies = $_COOKIE;
$this->headers = $this->parseHeaders(); $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 private function parseHeaders(): array
{ {
$headers = []; $headers = [];
foreach ($_SERVER as $key => $value) { foreach ($this->server as $key => $value) {
if (str_starts_with($key, 'HTTP_')) { if (str_starts_with($key, 'HTTP_')) {
$name = str_replace('_', '-', strtolower(substr($key, 5))); $name = str_replace('_', '-', strtolower(substr($key, 5)));
$headers[$name] = $value; $headers[$name] = $value;
} }
} }
if (isset($this->server['CONTENT_TYPE'])) {
$headers['content-type'] = $this->server['CONTENT_TYPE'];
}
return $headers; return $headers;
} }
public function method(): string { return $this->method; } public function method(): string
public function uri(): string { return $this->uri; } {
public function isJson(): bool { return str_contains($this->header('content-type', ''), 'json'); } $method = strtoupper($this->server['REQUEST_METHOD'] ?? 'GET');
public function isAjax(): bool { return $this->header('x-requested-with') === 'XMLHttpRequest'; } if ($method === 'POST') {
public function wantsJson(): bool { return str_contains($this->header('accept', ''), 'json') || $this->isJson(); } $override = $this->input('_method') ?? $this->header('x-http-method-override');
if ($override) {
$method = strtoupper($override);
}
}
return $method;
}
public function uri(): string
{
$uri = $this->server['REQUEST_URI'] ?? '/';
$pos = strpos($uri, '?');
return $pos !== false ? substr($uri, 0, $pos) : $uri;
}
public function path(): string
{
return rtrim($this->uri(), '/') ?: '/';
}
public function query(string $key, mixed $default = null): mixed public function query(string $key = '', mixed $default = null): mixed
{ {
if ($key === '') return $this->query;
return $this->query[$key] ?? $default; return $this->query[$key] ?? $default;
} }
public function input(string $key, mixed $default = null): mixed public function input(string $key = '', mixed $default = null): mixed
{ {
return $this->body[$key] ?? $this->query[$key] ?? $default; $body = $this->all();
if ($key === '') return $body;
return $body[$key] ?? $default;
} }
public function all(): array public function all(): array
{ {
return array_merge($this->query, $this->body); $data = array_merge($this->query, $this->post);
$json = $this->json();
if ($json) {
$data = array_merge($data, $json);
}
return $data;
} }
public function only(array $keys): array public function only(array $keys): array
{ {
return array_intersect_key($this->all(), array_flip($keys)); $all = $this->all();
return array_intersect_key($all, array_flip($keys));
} }
public function file(string $key): ?array public function json(): ?array
{ {
return $this->files[$key] ?? null; if ($this->jsonBody !== null) return $this->jsonBody;
$contentType = $this->header('content-type', '');
if (str_contains($contentType, 'application/json')) {
$raw = file_get_contents('php://input');
if ($raw) {
$this->jsonBody = json_decode($raw, true);
if (!is_array($this->jsonBody)) {
$this->jsonBody = [];
}
} else {
$this->jsonBody = [];
}
}
return $this->jsonBody;
} }
public function header(string $key, mixed $default = null): mixed public function setJsonBody(array $body): void
{ {
return $this->headers[strtolower($key)] ?? $default; $this->jsonBody = $body;
} }
public function cookie(string $key, mixed $default = null): mixed public function header(string $key, ?string $default = null): ?string
{ {
return $this->cookies[$key] ?? $default; return $this->headers[strtolower($key)] ?? $default;
} }
public function ip(): string public function ip(): string
...@@ -118,12 +130,29 @@ final class Request ...@@ -118,12 +130,29 @@ final class Request
public function userAgent(): string public function userAgent(): string
{ {
return $this->server['HTTP_USER_AGENT'] ?? ''; return $this->server['HTTP_USER_AGENT'] ?? 'Unknown';
} }
public function setAttribute(string $key, mixed $value): void public function wantsJson(): bool
{ {
$this->attributes[$key] = $value; $accept = $this->header('accept', '');
$xhr = $this->header('x-requested-with', '');
return str_contains($accept, 'application/json') || strtolower($xhr) === 'xmlhttprequest';
}
public function isAjax(): bool
{
return $this->wantsJson();
}
public function user(): ?array
{
return $this->user;
}
public function setUser(?array $user): void
{
$this->user = $user;
} }
public function getAttribute(string $key, mixed $default = null): mixed public function getAttribute(string $key, mixed $default = null): mixed
...@@ -131,9 +160,14 @@ final class Request ...@@ -131,9 +160,14 @@ final class Request
return $this->attributes[$key] ?? $default; return $this->attributes[$key] ?? $default;
} }
public function user(): ?array public function setAttribute(string $key, mixed $value): void
{ {
return $this->attributes['user'] ?? null; $this->attributes[$key] = $value;
}
public function cookie(string $key, ?string $default = null): ?string
{
return $this->cookies[$key] ?? $default;
} }
public function bearerToken(): ?string public function bearerToken(): ?string
......
...@@ -5,94 +5,53 @@ namespace Engine\Core; ...@@ -5,94 +5,53 @@ namespace Engine\Core;
final class Response final class Response
{ {
private int $statusCode;
private array $headers;
private string $body; private string $body;
private int $status;
private array $headers;
public function __construct(string $body = '', int $statusCode = 200, array $headers = []) public function __construct(string $body = '', int $status = 200, array $headers = [])
{ {
$this->body = $body; $this->body = $body;
$this->statusCode = $statusCode; $this->status = $status;
$this->headers = $headers; $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 public static function html(string $html, int $status = 200): self
{ {
return new self($html, $status, ['Content-Type' => 'text/html; charset=utf-8']); return new self($html, $status, ['Content-Type' => 'text/html; charset=utf-8']);
} }
public static function redirect(string $url, int $status = 302): self public static function json(mixed $data, int $status = 200): self
{ {
return new self('', $status, ['Location' => $url]); return new self(
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$status,
['Content-Type' => 'application/json; charset=utf-8']
);
} }
public static function download(string $filePath, string $filename): self public static function redirect(string $url, int $status = 302): 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 new self('', $status, ['Location' => $url]);
return $this;
} }
public function withCookie(string $name, string $value, int $expire = 0, string $path = '/', bool $httpOnly = true, bool $secure = true, string $sameSite = 'Lax'): self public static function make(string $body, int $status = 200, array $headers = []): self
{ {
setcookie($name, $value, [ return new self($body, $status, $headers);
'expires' => $expire,
'path' => $path,
'httponly' => $httpOnly,
'secure' => $secure,
'samesite' => $sameSite,
]);
return $this;
} }
public function send(): void public function send(): void
{ {
http_response_code($this->statusCode); if (!headers_sent()) {
http_response_code($this->status);
foreach ($this->headers as $key => $value) { foreach ($this->headers as $name => $value) {
header("{$key}: {$value}"); header("{$name}: {$value}");
} }
if (str_starts_with($this->body, '__FILE__:')) {
$file = substr($this->body, 9);
readfile($file);
} else {
echo $this->body;
} }
echo $this->body;
} }
public function getStatusCode(): int { return $this->statusCode; }
public function getBody(): string { return $this->body; } public function getBody(): string { return $this->body; }
public function getStatus(): int { return $this->status; }
public function getHeaders(): array { return $this->headers; }
} }
\ No newline at end of file
This diff is collapsed.
...@@ -3,57 +3,39 @@ declare(strict_types=1); ...@@ -3,57 +3,39 @@ declare(strict_types=1);
namespace Engine\Database; namespace Engine\Database;
use PDO;
final class Connection final class Connection
{ {
private ?PDO $pdo = null; private \PDO $pdo;
private array $config; private static ?Connection $instance = null;
public function __construct(array $config)
{
$this->config = $config;
}
public function pdo(): PDO public function __construct()
{ {
if ($this->pdo === null) { $config = require ROOT_PATH . '/config/database.php';
$dsn = sprintf( $dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=%s',
'mysql:host=%s;port=%d;dbname=%s;charset=%s', $config['host'], $config['port'], $config['database'], $config['charset']
$this->config['host'], );
$this->config['port'],
$this->config['database'],
$this->config['charset']
);
$options = $this->config['options'] ?? []; $this->pdo = new \PDO($dsn, $config['username'], $config['password'], $config['options'] ?? []);
// Ensure SSL cert verification is disabled for Docker internal networking
if (!isset($options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT])) {
$options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false;
}
$this->pdo = new PDO($dsn, $this->config['username'], $this->config['password'], $options);
}
return $this->pdo;
} }
public function getPdo(): \PDO { return $this->pdo; }
public function query(string $sql, array $params = []): \PDOStatement public function query(string $sql, array $params = []): \PDOStatement
{ {
$stmt = $this->pdo()->prepare($sql); $stmt = $this->pdo->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
return $stmt; return $stmt;
} }
public function fetchOne(string $sql, array $params = []): ?array public function fetchAll(string $sql, array $params = []): array
{ {
$result = $this->query($sql, $params)->fetch(); return $this->query($sql, $params)->fetchAll();
return $result ?: null;
} }
public function fetchAll(string $sql, array $params = []): array public function fetchOne(string $sql, array $params = []): ?array
{ {
return $this->query($sql, $params)->fetchAll(); $result = $this->query($sql, $params)->fetch();
return $result ?: null;
} }
public function fetchColumn(string $sql, array $params = []): mixed public function fetchColumn(string $sql, array $params = []): mixed
...@@ -67,53 +49,38 @@ final class Connection ...@@ -67,53 +49,38 @@ final class Connection
$placeholders = implode(', ', array_fill(0, count($data), '?')); $placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO `{$table}` ({$columns}) VALUES ({$placeholders})"; $sql = "INSERT INTO `{$table}` ({$columns}) VALUES ({$placeholders})";
$this->query($sql, array_values($data)); $this->query($sql, array_values($data));
return (int) $this->pdo()->lastInsertId(); return (int)$this->pdo->lastInsertId();
} }
public function update(string $table, array $data, string $where, array $whereParams = []): int public function update(string $table, array $data, string $where, array $whereParams = []): int
{ {
$sets = implode(', ', array_map(fn($c) => "`{$c}` = ?", array_keys($data))); $set = implode(', ', array_map(fn($c) => "`{$c}` = ?", array_keys($data)));
$sql = "UPDATE `{$table}` SET {$sets} WHERE {$where}"; $sql = "UPDATE `{$table}` SET {$set} WHERE {$where}";
$stmt = $this->query($sql, array_merge(array_values($data), $whereParams)); $stmt = $this->query($sql, array_merge(array_values($data), $whereParams));
return $stmt->rowCount(); return $stmt->rowCount();
} }
public function delete(string $table, string $where, array $params = []): int public function delete(string $table, string $where, array $params = []): int
{ {
$sql = "DELETE FROM `{$table}` WHERE {$where}"; $stmt = $this->query("DELETE FROM `{$table}` WHERE {$where}", $params);
return $this->query($sql, $params)->rowCount(); return $stmt->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 public function transaction(callable $callback): mixed
{ {
$this->beginTransaction(); $this->pdo->beginTransaction();
try { try {
$result = $callback($this); $result = $callback($this);
$this->commit(); $this->pdo->commit();
return $result; return $result;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->rollBack(); $this->pdo->rollBack();
throw $e; throw $e;
} }
} }
public function lastInsertId(): string public function lastInsertId(): int
{ {
return $this->pdo()->lastInsertId(); return (int)$this->pdo->lastInsertId();
} }
} }
\ No newline at end of file
...@@ -6,185 +6,58 @@ namespace Engine\Database; ...@@ -6,185 +6,58 @@ namespace Engine\Database;
final class QueryBuilder final class QueryBuilder
{ {
private Connection $db; private Connection $db;
private string $table; private string $table = '';
private array $selects = ['*']; private array $selects = ['*'];
private array $wheres = []; private array $wheres = [];
private array $bindings = []; private array $params = [];
private array $orderBy = [];
private ?int $limit = null;
private ?int $offset = null;
private array $joins = []; private array $joins = [];
private array $orderBys = [];
private array $groupBys = [];
private ?int $limitVal = null;
private ?int $offsetVal = null;
public function __construct(Connection $db, string $table) public function __construct(Connection $db) { $this->db = $db; }
{
$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 public function table(string $table): self { $c = clone $this; $c->table = $table; return $c; }
{ public function select(string ...$cols): self { $c = clone $this; $c->selects = $cols; return $c; }
$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 public function where(string $col, string $op, mixed $value): self
{ {
$this->joins[] = "{$type} JOIN `{$table}` ON {$first} {$operator} {$second}"; $c = clone $this;
return $this; $c->wheres[] = "`{$col}` {$op} ?";
$c->params[] = $value;
return $c;
} }
public function leftJoin(string $table, string $first, string $operator, string $second): self public function whereRaw(string $sql, array $params = []): self
{ {
return $this->join($table, $first, $operator, $second, 'LEFT'); $c = clone $this;
$c->wheres[] = $sql;
$c->params = array_merge($c->params, $params);
return $c;
} }
public function orderBy(string $column, string $direction = 'ASC'): self public function orderBy(string $col, string $dir = 'ASC'): self
{ {
$this->orderBys[] = "`{$column}` {$direction}"; $c = clone $this;
return $this; $c->orderBy[] = "`{$col}` " . (strtoupper($dir) === 'DESC' ? 'DESC' : 'ASC');
return $c;
} }
public function groupBy(string ...$columns): self public function limit(int $limit): self { $c = clone $this; $c->limit = $limit; return $c; }
{ public function offset(int $offset): self { $c = clone $this; $c->offset = $offset; return $c; }
$this->groupBys = array_merge($this->groupBys, $columns);
return $this;
}
public function limit(int $limit): self public function get(): array { return $this->db->fetchAll($this->toSql(), $this->params); }
{ public function first(): ?array { return $this->limit(1)->db->fetchOne($this->toSql(), $this->params); }
$this->limitVal = $limit; public function count(): int { $c = clone $this; $c->selects = ['COUNT(*)']; return (int)$this->db->fetchColumn($c->toSql(), $c->params); }
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 public function toSql(): string
{ {
$sql = 'SELECT ' . implode(', ', $this->selects); $sql = 'SELECT ' . implode(', ', $this->selects) . " FROM `{$this->table}`";
$sql .= " FROM `{$this->table}`"; if ($this->joins) $sql .= ' ' . implode(' ', $this->joins);
if ($this->wheres) $sql .= ' WHERE ' . implode(' AND ', $this->wheres);
foreach ($this->joins as $join) { if ($this->orderBy) $sql .= ' ORDER BY ' . implode(', ', $this->orderBy);
$sql .= " {$join}"; if ($this->limit !== null) $sql .= " LIMIT {$this->limit}";
} if ($this->offset !== null) $sql .= " OFFSET {$this->offset}";
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; return $sql;
} }
} }
\ No newline at end of file
...@@ -6,44 +6,28 @@ namespace Engine\Events; ...@@ -6,44 +6,28 @@ namespace Engine\Events;
final class EventDispatcher final class EventDispatcher
{ {
private array $listeners = []; private array $listeners = [];
private int $depth = 0;
private int $maxDepth = 10;
public function listen(string $event, callable $listener): void public function listen(string $event, string|callable $listener): void
{ {
$this->listeners[$event][] = $listener; $this->listeners[$event][] = $listener;
} }
public function fire(string $event, array $payload = []): void public function fire(string $event, array $payload = []): void
{ {
$this->depth++; $listeners = $this->listeners[$event] ?? [];
if ($this->depth > $this->maxDepth) { foreach ($listeners as $listener) {
$this->depth--; try {
error_log("[EventDispatcher] Max depth reached for event: {$event}"); if (is_callable($listener)) {
return; $listener($event, $payload);
} } elseif (is_string($listener) && class_exists($listener)) {
$instance = new $listener();
try { if ($instance instanceof ListenerInterface) {
$listeners = $this->listeners[$event] ?? []; $instance->handle($event, $payload);
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);
} }
} }
} catch (\Throwable $e) {
error_log("Event listener error [{$event}]: " . $e->getMessage());
} }
} finally {
$this->depth--;
} }
} }
public function hasListeners(string $event): bool
{
return !empty($this->listeners[$event]);
}
} }
\ No newline at end of file
...@@ -5,5 +5,5 @@ namespace Engine\Events; ...@@ -5,5 +5,5 @@ namespace Engine\Events;
interface ListenerInterface interface ListenerInterface
{ {
public function handle(array $payload): void; public function handle(string $event, array $payload): void;
} }
\ No newline at end of file
...@@ -5,37 +5,14 @@ namespace Engine\Export; ...@@ -5,37 +5,14 @@ namespace Engine\Export;
final class ExportManager final class ExportManager
{ {
private string $exportDir; public function toCsv(array $rows, array $headers = []): string
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'); if (empty($rows)) return '';
header("Content-Disposition: attachment; filename=\"{$filename}\""); ob_start();
$fp = fopen('php://output', 'w'); $out = fopen('php://output', 'w');
fputcsv($fp, $headers); fputcsv($out, $headers ?: array_keys($rows[0]));
foreach ($data as $row) { foreach ($rows as $row) fputcsv($out, array_values($row));
fputcsv($fp, $row); fclose($out);
} return ob_get_clean();
fclose($fp);
} }
} }
\ No newline at end of file
...@@ -3,99 +3,29 @@ declare(strict_types=1); ...@@ -3,99 +3,29 @@ declare(strict_types=1);
namespace Engine\FileStorage; namespace Engine\FileStorage;
use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
final class FileManager final class FileManager
{ {
private string $uploadDir;
private Connection $db; private Connection $db;
private array $allowedMimes = [ private string $uploadDir;
'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) public function __construct()
{ {
$this->uploadDir = rtrim($uploadDir, '/'); $this->db = Container::getInstance()->resolve(Connection::class);
$this->db = $db; $this->uploadDir = ROOT_PATH . '/storage/uploads';
} }
public function upload(array $file, int $uploadedBy): ?array public function upload(array $file, int $uploadedById): ?int
{ {
if ($file['error'] !== UPLOAD_ERR_OK) { if ($file['error'] !== UPLOAD_ERR_OK) return null;
return null; $storedName = bin2hex(random_bytes(16)) . '_' . preg_replace('/[^a-zA-Z0-9._-]/', '_', $file['name']);
} $path = $this->uploadDir . '/' . $storedName;
if (!move_uploaded_file($file['tmp_name'], $path)) return null;
$mime = mime_content_type($file['tmp_name']); return $this->db->insert('file_uploads', [
if (!in_array($mime, $this->allowedMimes)) { 'original_name' => $file['name'], 'stored_name' => $storedName, 'mime_type' => $file['type'],
throw new \RuntimeException("File type not allowed: {$mime}"); 'size_bytes' => $file['size'], 'storage_path' => 'storage/uploads/' . $storedName, 'uploaded_by_id' => $uploadedById,
}
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
...@@ -3,107 +3,76 @@ declare(strict_types=1); ...@@ -3,107 +3,76 @@ declare(strict_types=1);
namespace Engine\Notifications; namespace Engine\Notifications;
use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
final class NotificationManager final class NotificationManager
{ {
private Connection $db; private Connection $db;
public function __construct(Connection $db) public function __construct()
{ {
$this->db = $db; $this->db = Container::getInstance()->resolve(Connection::class);
} }
public function create(int $userId, string $tier, string $title, string $content, ?string $linkUrl = null, ?string $linkEntityType = null, ?int $linkEntityId = null): int public function createBlocking(int $userId, string $title, string $content, ?string $url = null, ?string $entityType = null, ?int $entityId = null): int
{ {
return $this->db->insert('notifications', [ return $this->create($userId, 'blocking', $title, $content, $url, $entityType, $entityId);
'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 public function createImportant(int $userId, string $title, string $content, ?string $url = null, ?string $entityType = null, ?int $entityId = null): int
{ {
return $this->create($userId, 'important', $title, $content, $linkUrl, $entityType, $entityId); return $this->create($userId, 'important', $title, $content, $url, $entityType, $entityId);
} }
public function createInformational(int $userId, string $title, string $content, ?string $linkUrl = null): int public function createInformational(int $userId, string $title, string $content, ?string $url = null, ?string $entityType = null, ?int $entityId = null): int
{
return $this->create($userId, 'informational', $title, $content, $linkUrl);
}
public function getUnreadCount(int $userId): int
{ {
return (int)$this->db->fetchColumn( return $this->create($userId, 'informational', $title, $content, $url, $entityType, $entityId);
"SELECT COUNT(*) FROM notifications WHERE user_id = ? AND is_read = 0",
[$userId]
);
} }
public function getBlockingUnacknowledged(int $userId): array private function create(int $userId, string $tier, string $title, string $content, ?string $url, ?string $entityType, ?int $entityId): int
{ {
return $this->db->fetchAll( return $this->db->insert('notifications', [
"SELECT * FROM notifications WHERE user_id = ? AND tier = 'blocking' AND is_acknowledged = 0 ORDER BY created_at ASC", 'user_id' => $userId,
[$userId] 'tier' => $tier,
); 'title' => $title,
'content' => $content,
'link_url' => $url,
'link_entity_type' => $entityType,
'link_entity_id' => $entityId,
]);
} }
public function hasBlocking(int $userId): bool public function getRecent(int $userId, int $limit = 10): array
{ {
return (bool)$this->db->fetchColumn( return $this->db->fetchAll("SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ?", [$userId, $limit]);
"SELECT COUNT(*) FROM notifications WHERE user_id = ? AND tier = 'blocking' AND is_acknowledged = 0",
[$userId]
);
} }
public function getRecent(int $userId, int $limit = 20): array public function getUnreadCount(int $userId): int
{ {
return $this->db->fetchAll( return (int)$this->db->fetchColumn("SELECT COUNT(*) FROM notifications WHERE user_id = ? AND is_read = 0", [$userId]);
"SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
[$userId, $limit]
);
} }
public function markAsRead(int $notificationId, int $userId): void public function getBlockingUnacknowledged(int $userId): array
{ {
$this->db->update('notifications', [ return $this->db->fetchAll("SELECT * FROM notifications WHERE user_id = ? AND tier = 'blocking' AND is_acknowledged = 0 ORDER BY created_at ASC", [$userId]);
'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 public function markAsRead(int $id, int $userId): void
{ {
$this->db->update('notifications', [ $this->db->update('notifications', ['is_read' => 1, 'read_at' => date('Y-m-d H:i:s')], 'id = ? AND user_id = ?', [$id, $userId]);
'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 public function markAllAsRead(int $userId): void
{ {
$this->db->update('notifications', [ $this->db->query("UPDATE notifications SET is_read = 1, read_at = NOW() WHERE user_id = ? AND is_read = 0", [$userId]);
'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 public function acknowledge(int $id, int $userId): void
{ {
foreach ($userIds as $userId) { $this->db->update('notifications', [
$this->create($userId, $tier, $title, $content, $linkUrl, $entityType, $entityId); 'is_acknowledged' => 1, 'acknowledged_at' => date('Y-m-d H:i:s'),
} 'is_read' => 1, 'read_at' => date('Y-m-d H:i:s'),
], 'id = ? AND user_id = ?', [$id, $userId]);
} }
} }
\ No newline at end of file
...@@ -6,4 +6,5 @@ namespace Engine\Scheduler; ...@@ -6,4 +6,5 @@ namespace Engine\Scheduler;
interface JobInterface interface JobInterface
{ {
public function run(): void; public function run(): void;
public function schedule(): string; // cron expression or interval
} }
\ No newline at end of file
...@@ -3,94 +3,48 @@ declare(strict_types=1); ...@@ -3,94 +3,48 @@ declare(strict_types=1);
namespace Engine\Scheduler; namespace Engine\Scheduler;
use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
final class JobRunner final class JobRunner
{ {
private Connection $db; private Connection $db;
private array $jobs = [];
public function __construct(Connection $db) public function __construct()
{ {
$this->db = $db; $this->db = Container::getInstance()->resolve(Connection::class);
} }
public function register(string $key, JobInterface $job): void public function runDue(): int
{ {
$this->jobs[$key] = $job; $jobs = require ROOT_PATH . '/config/scheduled_jobs.php';
} $ran = 0;
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) { foreach ($jobs as $key => $class) {
$key = $jobRecord['job_key']; if (!class_exists($class)) continue;
if (!isset($this->jobs[$key])) continue;
if ($jobRecord['last_status'] === 'running') continue; $record = $this->db->fetchOne("SELECT * FROM background_jobs WHERE job_key = ?", [$key]);
if (!$record) {
$this->db->insert('background_jobs', ['job_key' => $key, 'is_enabled' => 1]);
$record = $this->db->fetchOne("SELECT * FROM background_jobs WHERE job_key = ?", [$key]);
}
$this->db->update('background_jobs', ['last_status' => 'running'], 'id = ?', [$jobRecord['id']]); if (!$record['is_enabled']) continue;
try { try {
$job = $this->jobs[$key]; $this->db->update('background_jobs', ['last_status' => 'running'], 'job_key = ?', [$key]);
$instance = new $class();
// Check shouldRun() if method exists $instance->run();
if (method_exists($job, 'shouldRun') && !$job->shouldRun()) {
$this->db->update('background_jobs', [
'last_run_at' => date('Y-m-d H:i:s'),
'next_run_at' => $this->getNextRunAt($job),
'last_status' => 'success',
'last_error' => null,
], 'id = ?', [$jobRecord['id']]);
$results[$key] = 'skipped (shouldRun=false)';
continue;
}
$job->run();
$this->db->update('background_jobs', [ $this->db->update('background_jobs', [
'last_run_at' => date('Y-m-d H:i:s'), 'last_run_at' => date('Y-m-d H:i:s'), 'last_status' => 'success', 'last_error' => null,
'next_run_at' => $this->getNextRunAt($job), ], 'job_key = ?', [$key]);
'last_status' => 'success', $ran++;
'last_error' => null,
], 'id = ?', [$jobRecord['id']]);
$results[$key] = 'success';
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->db->update('background_jobs', [ $this->db->update('background_jobs', [
'last_run_at' => date('Y-m-d H:i:s'), 'last_run_at' => date('Y-m-d H:i:s'), 'last_status' => 'failed', 'last_error' => $e->getMessage(),
'next_run_at' => $this->getNextRunAt($this->jobs[$key]), ], 'job_key = ?', [$key]);
'last_status' => 'failed',
'last_error' => $e->getMessage(),
], 'id = ?', [$jobRecord['id']]);
$results[$key] = 'failed: ' . $e->getMessage();
} }
} }
return $ran;
return $results;
}
private function getNextRunAt(JobInterface $job): string
{
if (method_exists($job, 'nextRunAt')) {
return $job->nextRunAt();
}
// Default: run again in 1 hour
return date('Y-m-d H:i:s', strtotime('+1 hour'));
}
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
...@@ -3,77 +3,35 @@ declare(strict_types=1); ...@@ -3,77 +3,35 @@ declare(strict_types=1);
namespace Engine\Search; namespace Engine\Search;
use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
final class SearchEngine final class SearchEngine
{ {
private Connection $db; private Connection $db;
public function __construct(Connection $db) public function __construct()
{ {
$this->db = $db; $this->db = Container::getInstance()->resolve(Connection::class);
} }
public function search(string $query, array $user, int $limit = 50): array public function search(string $query, array $user): array
{ {
$results = [];
$q = '%' . $query . '%'; $q = '%' . $query . '%';
$results = [];
// Cards
$cards = $this->db->fetchAll( $cards = $this->db->fetchAll(
"SELECT c.id, c.card_key, c.title, c.board_id, b.name as board_name "SELECT id, card_key, title, 'card' as type FROM cards WHERE (card_key LIKE ? OR title LIKE ?) AND is_archived = 0 LIMIT 10",
FROM cards c [$q, $q]
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 = array_merge($results, $cards);
$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'])) {
if (in_array($user['role'], ['super_admin', 'admin', 'project_leader'])) { $users = $this->db->fetchAll("SELECT id, full_name_en as title, username as subtitle, 'user' as type FROM users WHERE (full_name_en LIKE ? OR username LIKE ?) LIMIT 10", [$q, $q]);
$users = $this->db->fetchAll( $results = array_merge($results, $users);
"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; $boards = $this->db->fetchAll("SELECT id, name as title, board_key as subtitle, 'board' as type FROM boards WHERE name LIKE ? AND is_archived = 0 LIMIT 5", [$q]);
return array_merge($results, $boards);
} }
} }
\ No newline at end of file
...@@ -5,110 +5,8 @@ namespace Engine\StateMachine; ...@@ -5,110 +5,8 @@ namespace Engine\StateMachine;
final class StateDefinition final class StateDefinition
{ {
public static function contractorStatus(): array public function __construct(
{ public readonly array $states,
return [ public readonly array $transitions,
'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
...@@ -3,83 +3,19 @@ declare(strict_types=1); ...@@ -3,83 +3,19 @@ declare(strict_types=1);
namespace Engine\StateMachine; namespace Engine\StateMachine;
use Engine\Audit\AuditLogger;
final class StateMachine final class StateMachine
{ {
private AuditLogger $audit; public function canTransition(StateDefinition $def, string $from, string $to): bool
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'] ?? []; $allowed = $def->transitions[$from] ?? [];
return in_array($to, $allowed, true);
} }
public function getAvailableTransitions(string $entity, string $currentState): array public function transition(StateDefinition $def, string $from, string $to): string
{ {
$available = []; if (!$this->canTransition($def, $from, $to)) {
$transitions = $this->definitions[$entity]['transitions'] ?? []; throw new \RuntimeException("Invalid transition from [{$from}] to [{$to}].");
foreach ($transitions as $t) {
if ($t['from'] === $currentState || $t['from'] === '*') {
$available[] = $t['to'];
}
} }
return array_unique($available); return $to;
} }
} }
\ No newline at end of file
...@@ -3,93 +3,46 @@ declare(strict_types=1); ...@@ -3,93 +3,46 @@ declare(strict_types=1);
namespace Engine\Template; namespace Engine\Template;
/**
* Simple PHP template engine with layout support.
*
* Usage:
* $engine->render('dashboard/super_admin', ['user' => $user]);
*
* Templates can specify layout at the top:
* (no explicit call needed — all templates use app layout by default for logged-in users)
*/
final class TemplateEngine final class TemplateEngine
{ {
private string $basePath; private string $basePath;
private string $cachePath;
private string $defaultLayout = 'layouts/app';
public function __construct(string $basePath = '', string $cachePath = '') public function __construct(string $basePath = '')
{ {
$this->basePath = $basePath ?: (defined('ROOT_PATH') ? ROOT_PATH . '/templates' : __DIR__ . '/../../templates'); $this->basePath = $basePath ?: ROOT_PATH . '/templates';
$this->cachePath = $cachePath ?: (defined('ROOT_PATH') ? ROOT_PATH . '/storage/cache/templates' : '/tmp');
} }
/**
* Render a template with data and wrap in layout.
*/
public function render(string $template, array $data = [], ?string $layout = null): string public function render(string $template, array $data = [], ?string $layout = null): string
{ {
$templateFile = $this->basePath . '/' . $template . '.php'; $file = $this->basePath . '/' . $template . '.php';
if (!file_exists($file)) {
if (!file_exists($templateFile)) { throw new \RuntimeException("Template not found: {$template}");
throw new \RuntimeException("Template not found: {$template} (looked in {$templateFile})");
} }
// Render the child template first $content = $this->renderFile($file, $data);
$content = $this->renderFile($templateFile, $data);
if ($layout === 'none') return $content;
// Determine layout
$layoutName = $layout; $layoutName = $layout;
if ($layoutName === null) { if ($layoutName === null) {
// Auto-detect: use 'auth' layout for auth pages, 'app' for everything else $layoutName = (str_starts_with($template, 'auth/') || str_starts_with($template, 'errors/'))
if (str_starts_with($template, 'auth/') || str_starts_with($template, 'errors/')) { ? 'layouts/auth' : 'layouts/app';
$layoutName = 'layouts/auth';
} else {
$layoutName = $this->defaultLayout;
}
}
if ($layoutName === 'none' || $layoutName === false || $layoutName === '') {
return $content;
} }
$layoutFile = $this->basePath . '/' . $layoutName . '.php'; $layoutFile = $this->basePath . '/' . $layoutName . '.php';
if (!file_exists($layoutFile)) { if (!file_exists($layoutFile)) return $content;
// No layout file? Just return the content directly.
return $content;
}
// Render layout with content injected return $this->renderFile($layoutFile, array_merge($data, ['content' => $content]));
$layoutData = array_merge($data, ['content' => $content]);
return $this->renderFile($layoutFile, $layoutData);
} }
/** public function e(string $value): string { return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); }
* Render a partial (no layout wrapping).
*/
public function partial(string $template, array $data = []): string
{
$file = $this->basePath . '/' . $template . '.php';
if (!file_exists($file)) {
return "<!-- partial not found: {$template} -->";
}
return $this->renderFile($file, $data);
}
/**
* Render a PHP file with extracted data and return output as string.
*/
private function renderFile(string $file, array $data): string private function renderFile(string $file, array $data): string
{ {
$__engine = $this;
extract($data, EXTR_SKIP); extract($data, EXTR_SKIP);
ob_start(); ob_start();
try { try { require $file; } catch (\Throwable $e) { ob_end_clean(); throw $e; }
require $file;
} catch (\Throwable $e) {
ob_end_clean();
throw new \RuntimeException("Template render error in {$file}: " . $e->getMessage(), 0, $e);
}
return ob_get_clean(); return ob_get_clean();
} }
} }
\ No newline at end of file
...@@ -3,35 +3,30 @@ declare(strict_types=1); ...@@ -3,35 +3,30 @@ declare(strict_types=1);
namespace Engine\Validation; namespace Engine\Validation;
use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
final class Validator final class Validator
{ {
private Connection $db;
private array $errors = []; private array $errors = [];
public function __construct(Connection $db)
{
$this->db = $db;
}
public function validate(array $data, array $rules): bool public function validate(array $data, array $rules): bool
{ {
$this->errors = []; $this->errors = [];
foreach ($rules as $field => $ruleSet) { foreach ($rules as $field => $ruleString) {
$fieldRules = explode('|', $ruleString);
$value = $data[$field] ?? null; $value = $data[$field] ?? null;
$ruleList = is_string($ruleSet) ? explode('|', $ruleSet) : $ruleSet;
foreach ($ruleList as $rule) { foreach ($fieldRules as $rule) {
$params = []; $params = [];
if (str_contains($rule, ':')) { if (str_contains($rule, ':')) {
[$rule, $paramStr] = explode(':', $rule, 2); [$rule, $paramStr] = explode(':', $rule, 2);
$params = explode(',', $paramStr); $params = explode(',', $paramStr);
} }
$error = $this->applyRule($field, $value, $rule, $params, $data); $error = $this->checkRule($field, $value, $rule, $params, $data);
if ($error !== null) { if ($error) {
$this->errors[$field][] = $error; $this->errors[$field][] = $error;
} }
} }
...@@ -40,128 +35,56 @@ final class Validator ...@@ -40,128 +35,56 @@ final class Validator
return empty($this->errors); return empty($this->errors);
} }
private function applyRule(string $field, mixed $value, string $rule, array $params, array $data): ?string private function checkRule(string $field, mixed $value, string $rule, array $params, array $data): ?string
{ {
$label = str_replace('_', ' ', $field); $label = str_replace('_', ' ', $field);
return match($rule) { return match ($rule) {
'required' => ($value === null || $value === '' || $value === []) 'required' => ($value === null || $value === '') ? "{$label} is required." : null,
? "{$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 an integer." : null,
'string' => (!is_string($value) && $value !== null) 'numeric' => (!is_numeric($value) && $value !== null) ? "{$label} must be numeric." : null,
? "{$label} must be a string." : null, 'date' => ($value && !strtotime($value)) ? "{$label} must be a valid date." : null,
'min' => (strlen((string)$value) < (int)$params[0]) ? "{$label} must be at least {$params[0]} characters." : null,
'integer' => (!is_numeric($value) && $value !== null) 'max' => (strlen((string)$value) > (int)$params[0]) ? "{$label} must be at most {$params[0]} characters." : null,
? "{$label} must be a number." : null, 'min_value' => (is_numeric($value) && (float)$value < (float)$params[0]) ? "{$label} must be at least {$params[0]}." : null,
'in' => (!in_array($value, $params)) ? "{$label} must be one of: " . implode(', ', $params) : null,
'numeric' => (!is_numeric($value) && $value !== null) 'matches' => ($value !== ($data[$params[0]] ?? null)) ? "{$label} must match {$params[0]}." : null,
? "{$label} must be numeric." : null, 'unique' => $this->checkUnique($value, $params, $label),
'password_strength' => $this->checkPasswordStrength($value, $label),
'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, default => null,
}; };
} }
private function checkUnique(string $field, mixed $value, array $params): ?string private function checkUnique(mixed $value, array $params, string $label): ?string
{ {
if ($value === null || $value === '') return null; if (!$value || count($params) < 2) return null;
try {
$table = $params[0] ?? ''; $db = Container::getInstance()->resolve(Connection::class);
$column = $params[1] ?? $field; $exists = $db->fetchColumn("SELECT COUNT(*) FROM `{$params[0]}` WHERE `{$params[1]}` = ?", [$value]);
$exceptId = $params[2] ?? null; return $exists > 0 ? "{$label} is already taken." : null;
} catch (\Throwable $e) {
$sql = "SELECT COUNT(*) FROM `{$table}` WHERE `{$column}` = ?"; return null;
$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 private function checkPasswordStrength(mixed $value, string $label): ?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 (!$value) return null;
if (strlen($value) < 10) return "Password must be at least 10 characters."; 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 an uppercase letter.";
if (!preg_match('/[a-z]/', $value)) return "Password must contain at least one lowercase letter."; if (!preg_match('/[a-z]/', $value)) return "Password must contain a lowercase letter.";
if (!preg_match('/[0-9]/', $value)) return "Password must contain at least one number."; if (!preg_match('/[0-9]/', $value)) return "Password must contain a number.";
if (!preg_match('/[^A-Za-z0-9]/', $value)) return "Password must contain at least one special character.";
return null; return null;
} }
public function errors(): array public function errors(): array { return $this->errors; }
{
return $this->errors;
}
public function firstError(): ?string public function firstError(): string
{ {
foreach ($this->errors as $fieldErrors) { foreach ($this->errors as $fieldErrors) {
return $fieldErrors[0] ?? null; return $fieldErrors[0] ?? 'Validation error.';
} }
return null; return 'Validation error.';
} }
} }
\ No newline at end of file
...@@ -3,95 +3,44 @@ declare(strict_types=1); ...@@ -3,95 +3,44 @@ declare(strict_types=1);
namespace Middleware; namespace Middleware;
use Engine\Core\Container; use Engine\Core\{Request, Response, Container};
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection; use Engine\Database\Connection;
use Engine\Auth\PasswordHasher;
final class ApiKeyAuthMiddleware final class ApiKeyAuthMiddleware
{ {
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function handle(Request $request, callable $next): Response public function handle(Request $request, callable $next): Response
{ {
$authHeader = $request->header('Authorization', ''); $authHeader = $request->header('authorization', '');
$apiKey = null; $apiKey = $request->header('x-api-key', '');
if (str_starts_with($authHeader, 'Bearer ')) { if (str_starts_with($authHeader, 'Bearer ')) {
$apiKey = substr($authHeader, 7); $apiKey = substr($authHeader, 7);
} }
if (!$apiKey) { if (!$apiKey) {
$apiKey = $request->header('X-API-Key', ''); return Response::json(['error' => 'API key required'], 401);
}
if (!$apiKey) {
return Response::json(['error' => 'API key required. Provide via Authorization: Bearer <key> or X-API-Key header.'], 401);
} }
$db = Container::getInstance()->resolve(Connection::class);
$prefix = substr($apiKey, 0, 8); $prefix = substr($apiKey, 0, 8);
$keyRecord = $this->db->fetchOne( $candidates = $db->fetchAll("SELECT * FROM api_keys WHERE key_prefix = ? AND revoked_at IS NULL", [$prefix]);
"SELECT * FROM api_keys WHERE key_prefix = ? AND revoked_at IS NULL",
[$prefix]
);
if (!$keyRecord) {
return Response::json(['error' => 'Invalid or revoked API key.'], 401);
}
if (!password_verify($apiKey, $keyRecord['key_hash'])) {
return Response::json(['error' => 'Invalid API key.'], 401);
}
// Rate limiting
$hourAgo = date('Y-m-d H:i:s', strtotime('-1 hour'));
$requestCount = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM audit_trail WHERE ip_address = ? AND created_at > ? AND endpoint LIKE '/api/%'",
[$request->ip(), $hourAgo]
);
if ($requestCount >= $keyRecord['rate_limit_per_hour']) {
return Response::json([
'error' => 'Rate limit exceeded.',
'limit' => $keyRecord['rate_limit_per_hour'],
'retry_after' => 3600,
], 429);
}
// Update last used
$this->db->update('api_keys', ['last_used_at' => date('Y-m-d H:i:s')], 'id = ?', [$keyRecord['id']]);
// Resolve the user who created this key to act on their behalf $matched = null;
$user = $this->db->fetchOne("SELECT * FROM users WHERE id = ? AND is_active = 1", [$keyRecord['created_by_id']]); foreach ($candidates as $candidate) {
if (!$user) { if (password_verify($apiKey, $candidate['key_hash'])) {
return Response::json(['error' => 'API key owner account is disabled.'], 403); $matched = $candidate;
} break;
}
// Enforce scope
$method = strtoupper($request->method());
$scope = $keyRecord['scope'];
if ($scope === 'read_only' && !in_array($method, ['GET', 'HEAD', 'OPTIONS'])) {
return Response::json(['error' => 'This API key has read-only scope.'], 403);
} }
if ($scope === 'read_write' && $method === 'DELETE') { if (!$matched) {
// read_write can't delete, only admin scope can return Response::json(['error' => 'Invalid API key'], 401);
$uri = $request->uri();
if (!str_contains($uri, '/api/auth/')) {
return Response::json(['error' => 'DELETE operations require admin-scoped API key.'], 403);
}
} }
$request->setUser($user); $db->update('api_keys', ['last_used_at' => date('Y-m-d H:i:s')], 'id = ?', [$matched['id']]);
$request->setAttribute('api_key_id', $keyRecord['id']); $owner = $db->fetchOne("SELECT * FROM users WHERE id = ?", [$matched['created_by_id']]);
$request->setAttribute('api_key_scope', $scope); if ($owner) $request->setUser($owner);
$request->setAttribute('is_api_request', true);
return $next($request); return $next($request);
} }
......
...@@ -3,42 +3,12 @@ declare(strict_types=1); ...@@ -3,42 +3,12 @@ declare(strict_types=1);
namespace Middleware; namespace Middleware;
use Engine\Core\Request; use Engine\Core\{Request, Response};
use Engine\Core\Response;
use Engine\Core\Container;
use Engine\Audit\AuditLogger;
final class AuditMiddleware final class AuditMiddleware
{ {
private AuditLogger $audit;
public function __construct()
{
$this->audit = Container::getInstance()->resolve(AuditLogger::class);
}
public function handle(Request $request, callable $next): Response public function handle(Request $request, callable $next): Response
{ {
$response = $next($request); return $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
...@@ -3,32 +3,26 @@ declare(strict_types=1); ...@@ -3,32 +3,26 @@ declare(strict_types=1);
namespace Middleware; namespace Middleware;
use Engine\Core\Request; use Engine\Core\{Request, Response, Container};
use Engine\Core\Response;
use Engine\Core\Container;
use Engine\Auth\SessionManager; use Engine\Auth\SessionManager;
final class AuthenticationMiddleware final class AuthenticationMiddleware
{ {
private SessionManager $sessions;
public function __construct()
{
$this->sessions = Container::getInstance()->resolve(SessionManager::class);
}
public function handle(Request $request, callable $next): Response public function handle(Request $request, callable $next): Response
{ {
$user = $this->sessions->validate(); $sessions = Container::getInstance()->resolve(SessionManager::class);
$user = $sessions->validate();
if (!$user) { if (!$user) {
if ($request->wantsJson()) { if ($request->wantsJson()) {
return Response::json(['error' => 'Unauthenticated'], 401); return Response::json(['error' => 'Authentication required'], 401);
} }
return Response::redirect('/login'); return Response::redirect('/login');
} }
$request->setAttribute('user', $user); $request->setUser($user);
$sessions->generateCsrfToken();
return $next($request); return $next($request);
} }
} }
\ No newline at end of file
...@@ -3,42 +3,23 @@ declare(strict_types=1); ...@@ -3,42 +3,23 @@ declare(strict_types=1);
namespace Middleware; namespace Middleware;
use Engine\Core\Request; use Engine\Core\{Request, Response, Container};
use Engine\Core\Response;
use Engine\Core\Container;
use Engine\Notifications\NotificationManager; use Engine\Notifications\NotificationManager;
final class BlockingNotificationMiddleware final class BlockingNotificationMiddleware
{ {
private NotificationManager $notifications;
public function __construct()
{
$this->notifications = Container::getInstance()->resolve(NotificationManager::class);
}
public function handle(Request $request, callable $next): Response public function handle(Request $request, callable $next): Response
{ {
$user = $request->user(); $user = $request->user();
if (!$user) { if (!$user || $request->wantsJson()) return $next($request);
return $next($request);
}
// Allow notification endpoints through $path = $request->path();
$uri = $request->uri(); $exempt = ['/notifications/blocking', '/notifications', '/logout', '/password/change', '/sse/stream'];
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')) { if (in_array($path, $exempt) || str_starts_with($path, '/api/')) return $next($request);
return $next($request);
}
if ($this->notifications->hasBlocking($user['id'])) { $notif = Container::getInstance()->resolve(NotificationManager::class);
if ($request->wantsJson()) { $blocking = $notif->getBlockingUnacknowledged($user['id']);
$blocking = $this->notifications->getBlockingUnacknowledged($user['id']); if (!empty($blocking)) {
return Response::json([
'blocked' => true,
'notification' => $blocking[0] ?? null,
], 403);
}
// For HTML requests, redirect to blocking notification page
return Response::redirect('/notifications/blocking'); return Response::redirect('/notifications/blocking');
} }
......
...@@ -3,24 +3,20 @@ declare(strict_types=1); ...@@ -3,24 +3,20 @@ declare(strict_types=1);
namespace Middleware; namespace Middleware;
use Engine\Core\Request; use Engine\Core\{Request, Response};
use Engine\Core\Response;
final class CORSMiddleware final class CORSMiddleware
{ {
public function handle(Request $request, callable $next): Response public function handle(Request $request, callable $next): Response
{ {
if ($request->method() === 'OPTIONS') { if ($request->method() === 'OPTIONS') {
return (new Response('', 204)) return Response::make('', 204, [
->withHeader('Access-Control-Allow-Origin', '*') 'Access-Control-Allow-Origin' => '*',
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') 'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-CSRF-Token, X-Requested-With') 'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-CSRF-Token, X-Requested-With',
->withHeader('Access-Control-Max-Age', '86400'); 'Access-Control-Max-Age' => '86400',
]);
} }
return $next($request);
$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
...@@ -3,42 +3,27 @@ declare(strict_types=1); ...@@ -3,42 +3,27 @@ declare(strict_types=1);
namespace Middleware; namespace Middleware;
use Engine\Core\Request; use Engine\Core\{Request, Response, Container};
use Engine\Core\Response; use Engine\Auth\SessionManager;
final class CSRFMiddleware final class CSRFMiddleware
{ {
public function handle(Request $request, callable $next): Response public function handle(Request $request, callable $next): Response
{ {
if (in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'])) { if (in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'])) {
$this->ensureToken();
return $next($request); return $next($request);
} }
$token = $request->input('_csrf_token') ?? $request->header('x-csrf-token'); $sessions = Container::getInstance()->resolve(SessionManager::class);
$sessionToken = $_COOKIE['csrf_token'] ?? ''; $token = $request->input('csrf_token') ?? $request->header('x-csrf-token', '');
if (!$token || !$sessionToken || !hash_equals($sessionToken, $token)) { if (!$token || !$sessions->verifyCsrfToken($token)) {
if ($request->wantsJson()) { if ($request->wantsJson()) {
return Response::json(['error' => 'CSRF token mismatch'], 419); 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 Response::redirect('/login?error=Session+expired');
} }
return $next($request); 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
...@@ -3,14 +3,16 @@ declare(strict_types=1); ...@@ -3,14 +3,16 @@ declare(strict_types=1);
namespace Middleware; namespace Middleware;
use Engine\Core\Request; use Engine\Core\{Request, Response};
use Engine\Core\Response;
final class JsonBodyParserMiddleware final class JsonBodyParserMiddleware
{ {
public function handle(Request $request, callable $next): Response public function handle(Request $request, callable $next): Response
{ {
// Body already parsed in Request::capture() $contentType = $request->header('content-type', '');
if (str_contains($contentType, 'application/json')) {
$request->json(); // Triggers parsing
}
return $next($request); return $next($request);
} }
} }
\ No newline at end of file
...@@ -3,37 +3,14 @@ declare(strict_types=1); ...@@ -3,37 +3,14 @@ declare(strict_types=1);
namespace Middleware; namespace Middleware;
use Engine\Core\Request; use Engine\Core\{Request, Response};
use Engine\Core\Response;
final class SecurityHeadersMiddleware final class SecurityHeadersMiddleware
{ {
public function handle(Request $request, callable $next): Response public function handle(Request $request, callable $next): Response
{ {
$response = $next($request); $response = $next($request);
// Headers are set in .htaccess / Apache config for static responses
$response->setHeader('X-Content-Type-Options', 'nosniff');
$response->setHeader('X-Frame-Options', 'SAMEORIGIN');
$response->setHeader('X-XSS-Protection', '1; mode=block');
$response->setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
$response->setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
$nonce = base64_encode(random_bytes(16));
$response->setHeader('Content-Security-Policy',
"default-src 'self'; " .
"script-src 'self' 'nonce-{$nonce}'; " .
"style-src 'self' 'unsafe-inline'; " .
"img-src 'self' data: blob:; " .
"font-src 'self'; " .
"connect-src 'self'; " .
"frame-ancestors 'self'; " .
"base-uri 'self'; " .
"form-action 'self';"
);
$response->setHeader('X-CSP-Nonce', $nonce);
return $response; return $response;
} }
} }
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
use Engine\Core\Container; Router::get('/adjustments', [\Modules\Adjustments\Controllers\AdjustmentController::class, 'index']);
Router::post('/adjustments', [\Modules\Adjustments\Controllers\AdjustmentController::class, 'create']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class); Router::post('/adjustments/{id}/approve', [\Modules\Adjustments\Controllers\AdjustmentController::class, 'approve']);
$router->group([
'prefix' => '/adjustments',
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class]
], function ($router) {
$router->get('/', Modules\Adjustments\Controllers\AdjustmentController::class, 'index');
$router->post('/', Modules\Adjustments\Controllers\AdjustmentController::class, 'create');
$router->post('/{id}/review', Modules\Adjustments\Controllers\AdjustmentController::class, 'approve');
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
Router::group('/analytics', ['middleware' => ['auth', 'audit']], function () { Router::get('/analytics', [\Modules\Analytics\Controllers\AnalyticsController::class, 'dashboard']);
Router::get('/', [\Modules\Analytics\Controllers\AnalyticsController::class, 'dashboard']); Router::get('/analytics/report-builder', [\Modules\Analytics\Controllers\ReportBuilderController::class, 'index']);
Router::get('/report-builder', [\Modules\Analytics\Controllers\ReportBuilderController::class, 'index']); Router::get('/analytics/report-builder/sources', [\Modules\Analytics\Controllers\ReportBuilderController::class, 'sources']);
Router::get('/report-builder/sources', [\Modules\Analytics\Controllers\ReportBuilderController::class, 'sources']); Router::post('/analytics/report-builder/execute', [\Modules\Analytics\Controllers\ReportBuilderController::class, 'execute']);
Router::post('/report-builder/execute', [\Modules\Analytics\Controllers\ReportBuilderController::class, 'execute']);
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::group('/api-keys', ['middleware' => ['auth', 'audit']], function () { Router::get('/api-keys', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'index']);
Router::get('/', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'index']); Router::post('/api-keys', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'create']);
Router::post('/', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'create']); Router::post('/api-keys/{keyId}/revoke', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'revoke']);
Router::post('/{keyId}/revoke', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'revoke']); Router::delete('/api-keys/{keyId}', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'delete']);
Router::delete('/{keyId}', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'delete']);
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::group('/audit-trail', ['middleware' => ['auth']], function () { Router::get('/audit-trail', [\Modules\AuditTrail\Controllers\AuditTrailController::class, 'index']);
Router::get('/', [\Modules\AuditTrail\Controllers\AuditTrailController::class, 'index']); Router::get('/audit-trail/stats', [\Modules\AuditTrail\Controllers\AuditTrailController::class, 'stats']);
Router::get('/stats', [\Modules\AuditTrail\Controllers\AuditTrailController::class, 'stats']); Router::get('/audit-trail/{entryId}', [\Modules\AuditTrail\Controllers\AuditTrailController::class, 'show']);
Router::get('/{entryId}', [\Modules\AuditTrail\Controllers\AuditTrailController::class, 'show']);
}); });
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
use Engine\Core\Container; Router::get('/login', [\Modules\Auth\Controllers\LoginController::class, 'showForm']);
Router::post('/login', [\Modules\Auth\Controllers\LoginController::class, 'login']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class); Router::group('', ['middleware' => ['auth']], function () {
Router::post('/logout', [\Modules\Auth\Controllers\LogoutController::class, 'logout']);
$router->get('/login', Modules\Auth\Controllers\LoginController::class, 'showForm'); Router::get('/password/change', [\Modules\Auth\Controllers\PasswordController::class, 'showChangeForm']);
$router->post('/login', Modules\Auth\Controllers\LoginController::class, 'login'); Router::post('/password/change', [\Modules\Auth\Controllers\PasswordController::class, 'change']);
Router::post('/users/{userId}/reset-password', [\Modules\Auth\Controllers\PasswordController::class, 'resetForUser']);
$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 <?php
use Engine\Core\Router; use Engine\Core\Router;
use Modules\BoardTemplates\Controllers\BoardTemplateController; Router::group('', ['middleware' => ['auth']], function () {
Router::get('/board-templates', [\Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'index']);
return function (Router $router) { Router::post('/board-templates', [\Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'create']);
$router->get('/board-templates', [BoardTemplateController::class, 'index']); Router::post('/board-templates/from-board/{boardId}', [\Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'saveFromBoard']);
$router->post('/board-templates', [BoardTemplateController::class, 'create']); Router::delete('/board-templates/{templateId}', [\Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'delete']);
$router->post('/board-templates/from-board/{boardId}', [BoardTemplateController::class, 'saveFromBoard']); });
$router->delete('/board-templates/{templateId}', [BoardTemplateController::class, 'delete']); \ No newline at end of file
$router->get('/api/board-templates', [BoardTemplateController::class, 'index']);
$router->post('/api/board-templates', [BoardTemplateController::class, 'create']);
$router->post('/api/board-templates/from-board/{boardId}', [BoardTemplateController::class, 'saveFromBoard']);
$router->delete('/api/board-templates/{templateId}', [BoardTemplateController::class, 'delete']);
};
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
use Engine\Core\Container; Router::get('/boards', [\Modules\Boards\Controllers\BoardController::class, 'index']);
Router::post('/boards', [\Modules\Boards\Controllers\BoardController::class, 'create']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class); Router::get('/boards/{boardId}', [\Modules\Boards\Controllers\BoardController::class, 'show']);
Router::put('/boards/{boardId}', [\Modules\Boards\Controllers\BoardController::class, 'update']);
$router->group([ Router::post('/boards/{boardId}/archive', [\Modules\Boards\Controllers\BoardController::class, 'archive']);
'prefix' => '/boards', Router::post('/boards/{boardId}/members', [\Modules\Boards\Controllers\BoardController::class, 'addMember']);
'middleware' => [ Router::delete('/boards/{boardId}/members/{memberId}', [\Modules\Boards\Controllers\BoardController::class, 'removeMember']);
Middleware\AuthenticationMiddleware::class, Router::post('/boards/{boardId}/columns', [\Modules\Boards\Controllers\BoardController::class, 'addColumn']);
Middleware\CSRFMiddleware::class,
Middleware\BlockingNotificationMiddleware::class,
]
], function ($router) {
$router->get('/', Modules\Boards\Controllers\BoardController::class, 'index');
$router->post('/', Modules\Boards\Controllers\BoardController::class, 'create');
$router->get('/{boardId}', Modules\Boards\Controllers\BoardController::class, 'show');
$router->post('/{boardId}/update', Modules\Boards\Controllers\BoardController::class, 'update');
$router->post('/{boardId}/archive', Modules\Boards\Controllers\BoardController::class, 'archive');
$router->post('/{boardId}/members', Modules\Boards\Controllers\BoardController::class, 'addMember');
$router->post('/{boardId}/members/{memberId}/remove', Modules\Boards\Controllers\BoardController::class, 'removeMember');
$router->post('/{boardId}/columns', Modules\Boards\Controllers\BoardController::class, 'addColumn');
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
use Modules\CardTemplates\Controllers\CardTemplateController; Router::group('', ['middleware' => ['auth']], function () {
Router::get('/card-templates', [\Modules\CardTemplates\Controllers\CardTemplateController::class, 'index']);
return function (Router $router) { Router::post('/card-templates', [\Modules\CardTemplates\Controllers\CardTemplateController::class, 'create']);
$router->get('/card-templates', [CardTemplateController::class, 'index']); Router::put('/card-templates/{templateId}', [\Modules\CardTemplates\Controllers\CardTemplateController::class, 'update']);
$router->post('/card-templates', [CardTemplateController::class, 'create']); Router::delete('/card-templates/{templateId}', [\Modules\CardTemplates\Controllers\CardTemplateController::class, 'delete']);
$router->put('/card-templates/{templateId}', [CardTemplateController::class, 'update']); });
$router->delete('/card-templates/{templateId}', [CardTemplateController::class, 'delete']); \ No newline at end of file
$router->get('/api/card-templates', [CardTemplateController::class, 'index']);
$router->post('/api/card-templates', [CardTemplateController::class, 'create']);
$router->put('/api/card-templates/{templateId}', [CardTemplateController::class, 'update']);
$router->delete('/api/card-templates/{templateId}', [CardTemplateController::class, 'delete']);
};
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
use Engine\Core\Container; Router::get('/cards/{cardId}', [\Modules\Cards\Controllers\CardController::class, 'show']);
Router::post('/boards/{boardId}/cards', [\Modules\Cards\Controllers\CardController::class, 'create']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class); Router::put('/cards/{cardId}', [\Modules\Cards\Controllers\CardController::class, 'update']);
Router::post('/cards/{cardId}/move', [\Modules\Cards\Controllers\CardController::class, 'move']);
$router->group([ Router::post('/cards/{cardId}/assign', [\Modules\Cards\Controllers\CardController::class, 'assign']);
'prefix' => '', Router::post('/cards/{cardId}/comments', [\Modules\Cards\Controllers\CardController::class, 'addComment']);
'middleware' => [ Router::put('/cards/{cardId}/comments/{commentId}', [\Modules\Cards\Controllers\CardController::class, 'editComment']);
Middleware\AuthenticationMiddleware::class, Router::post('/cards/{cardId}/bounty', [\Modules\Cards\Controllers\CardController::class, 'setBounty']);
Middleware\CSRFMiddleware::class, Router::post('/cards/{cardId}/watch', [\Modules\Cards\Controllers\CardController::class, 'watch']);
Middleware\BlockingNotificationMiddleware::class, Router::post('/cards/{cardId}/labels', [\Modules\Cards\Controllers\CardController::class, 'addLabel']);
] Router::delete('/cards/{cardId}/labels/{labelId}', [\Modules\Cards\Controllers\CardController::class, 'removeLabel']);
], function ($router) { Router::post('/cards/{cardId}/checklists', [\Modules\Cards\Controllers\CardController::class, 'addChecklist']);
$router->post('/boards/{boardId}/cards', Modules\Cards\Controllers\CardController::class, 'create'); Router::post('/cards/{cardId}/checklists/{checklistId}/items', [\Modules\Cards\Controllers\CardController::class, 'addChecklistItem']);
$router->get('/cards/{cardId}', Modules\Cards\Controllers\CardController::class, 'show'); Router::post('/cards/{cardId}/checklist-items/{itemId}/toggle', [\Modules\Cards\Controllers\CardController::class, 'toggleChecklistItem']);
$router->post('/cards/{cardId}', Modules\Cards\Controllers\CardController::class, 'update'); Router::post('/cards/{cardId}/archive', [\Modules\Cards\Controllers\CardController::class, 'archive']);
$router->post('/cards/{cardId}/move', Modules\Cards\Controllers\CardController::class, 'move'); Router::post('/cards/{cardId}/duplicate', [\Modules\Cards\Controllers\CardController::class, 'duplicate']);
$router->post('/cards/{cardId}/assign', Modules\Cards\Controllers\CardController::class, 'assign');
$router->post('/cards/{cardId}/comments', Modules\Cards\Controllers\CardController::class, 'addComment');
$router->post('/cards/{cardId}/comments/{commentId}/edit', Modules\Cards\Controllers\CardController::class, 'editComment');
$router->post('/cards/{cardId}/bounty', Modules\Cards\Controllers\CardController::class, 'setBounty');
$router->post('/cards/{cardId}/watch', Modules\Cards\Controllers\CardController::class, 'watch');
$router->post('/cards/{cardId}/labels', Modules\Cards\Controllers\CardController::class, 'addLabel');
$router->post('/cards/{cardId}/labels/{labelId}/remove', Modules\Cards\Controllers\CardController::class, 'removeLabel');
$router->post('/cards/{cardId}/checklists', Modules\Cards\Controllers\CardController::class, 'addChecklist');
$router->post('/cards/{cardId}/checklists/{checklistId}/items', Modules\Cards\Controllers\CardController::class, 'addChecklistItem');
$router->post('/cards/{cardId}/checklist-items/{itemId}/toggle', Modules\Cards\Controllers\CardController::class, 'toggleChecklistItem');
$router->post('/cards/{cardId}/archive', Modules\Cards\Controllers\CardController::class, 'archive');
$router->post('/cards/{cardId}/duplicate', Modules\Cards\Controllers\CardController::class, 'duplicate');
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
use Modules\Contracts\Controllers\ContractController; Router::group('', ['middleware' => ['auth']], function () {
use Modules\Contracts\Controllers\PolicyController; Router::get('/contracts', [\Modules\Contracts\Controllers\ContractController::class, 'index']);
use Modules\Contracts\Controllers\NoticeController; Router::get('/contracts/{contractId}', [\Modules\Contracts\Controllers\ContractController::class, 'show']);
Router::get('/policies', [\Modules\Contracts\Controllers\PolicyController::class, 'index']);
return function (Router $router) { Router::get('/policies/{policyId}', [\Modules\Contracts\Controllers\PolicyController::class, 'show']);
$router->get('/contracts', [ContractController::class, 'index']); Router::post('/policies', [\Modules\Contracts\Controllers\PolicyController::class, 'create']);
$router->get('/contracts/{contractId}', [ContractController::class, 'show']); Router::post('/policies/{policyId}/publish', [\Modules\Contracts\Controllers\PolicyController::class, 'publish']);
Router::post('/policies/versions/{versionId}/acknowledge', [\Modules\Contracts\Controllers\PolicyController::class, 'acknowledge']);
$router->get('/policies', [PolicyController::class, 'index']); Router::get('/notices', [\Modules\Contracts\Controllers\NoticeController::class, 'index']);
$router->get('/policies/{policyId}', [PolicyController::class, 'show']); Router::post('/notices', [\Modules\Contracts\Controllers\NoticeController::class, 'create']);
$router->post('/policies', [PolicyController::class, 'create']); Router::post('/notices/{noticeId}/acknowledge', [\Modules\Contracts\Controllers\NoticeController::class, 'acknowledgeNotice']);
$router->post('/policies/{policyId}/publish', [PolicyController::class, 'publish']); Router::delete('/notices/{noticeId}', [\Modules\Contracts\Controllers\NoticeController::class, 'delete']);
$router->post('/policies/versions/{versionId}/acknowledge', [PolicyController::class, 'acknowledge']); });
\ No newline at end of file
$router->get('/notices', [NoticeController::class, 'index']);
$router->post('/notices', [NoticeController::class, 'create']);
$router->post('/notices/{noticeId}/acknowledge', [NoticeController::class, 'acknowledgeNotice']);
$router->delete('/notices/{noticeId}', [NoticeController::class, 'delete']);
$router->get('/api/contracts', [ContractController::class, 'index']);
$router->get('/api/contracts/{contractId}', [ContractController::class, 'show']);
$router->get('/api/policies', [PolicyController::class, 'index']);
$router->get('/api/policies/{policyId}', [PolicyController::class, 'show']);
$router->post('/api/policies', [PolicyController::class, 'create']);
$router->post('/api/policies/{policyId}/publish', [PolicyController::class, 'publish']);
$router->post('/api/policies/versions/{versionId}/acknowledge', [PolicyController::class, 'acknowledge']);
$router->get('/api/notices', [NoticeController::class, 'index']);
$router->post('/api/notices', [NoticeController::class, 'create']);
$router->post('/api/notices/{noticeId}/acknowledge', [NoticeController::class, 'acknowledgeNotice']);
$router->delete('/api/notices/{noticeId}', [NoticeController::class, 'delete']);
};
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::group('/control-panel', ['middleware' => ['auth', 'audit']], function () { Router::get('/control-panel', [\Modules\ControlPanel\Controllers\ControlPanelController::class, 'index']);
Router::get('/', [\Modules\ControlPanel\Controllers\ControlPanelController::class, 'index']); Router::get('/control-panel/entities', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'entities']);
Router::get('/entities', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'entities']); Router::get('/control-panel/{entity}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'list']);
Router::get('/{entity}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'list']); Router::get('/control-panel/{entity}/{id}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'show']);
Router::get('/{entity}/{id}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'show']); Router::put('/control-panel/{entity}/{id}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'update']);
Router::put('/{entity}/{id}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'update']); Router::delete('/control-panel/{entity}/{id}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'delete']);
Router::delete('/{entity}/{id}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'delete']); Router::post('/control-panel/{entity}/bulk-delete', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'bulkDelete']);
Router::post('/{entity}/bulk-delete', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'bulkDelete']);
}); });
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
use Engine\Core\Container; Router::get('/dashboard', [\Modules\Dashboard\Controllers\DashboardController::class, 'index']);
Router::get('/', [\Modules\Dashboard\Controllers\DashboardController::class, 'index']);
$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 <?php
use Engine\Core\Router; use Engine\Core\Router;
use Modules\DeductionPresets\Controllers\DeductionPresetController; Router::group('', ['middleware' => ['auth']], function () {
Router::get('/deduction-presets', [\Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'index']);
return function (Router $router) { Router::post('/deduction-presets', [\Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'create']);
$router->get('/deduction-presets', [DeductionPresetController::class, 'index']); Router::put('/deduction-presets/{presetId}', [\Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'update']);
$router->post('/deduction-presets', [DeductionPresetController::class, 'create']); Router::delete('/deduction-presets/{presetId}', [\Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'delete']);
$router->put('/deduction-presets/{presetId}', [DeductionPresetController::class, 'update']); });
$router->delete('/deduction-presets/{presetId}', [DeductionPresetController::class, 'delete']); \ No newline at end of file
$router->get('/api/deduction-presets', [DeductionPresetController::class, 'index']);
$router->post('/api/deduction-presets', [DeductionPresetController::class, 'create']);
$router->put('/api/deduction-presets/{presetId}', [DeductionPresetController::class, 'update']);
$router->delete('/api/deduction-presets/{presetId}', [DeductionPresetController::class, 'delete']);
};
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
use Engine\Core\Container; Router::get('/deductions', [\Modules\Deductions\Controllers\DeductionController::class, 'index']);
Router::get('/deductions/{deductionId}', [\Modules\Deductions\Controllers\DeductionController::class, 'show']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class); Router::post('/deductions', [\Modules\Deductions\Controllers\DeductionController::class, 'initiate']);
Router::post('/deductions/{deductionId}/acknowledge', [\Modules\Deductions\Controllers\DeductionController::class, 'acknowledge']);
$router->group([ Router::post('/deductions/{deductionId}/respond', [\Modules\Deductions\Controllers\DeductionController::class, 'respond']);
'prefix' => '/deductions', Router::post('/deductions/{deductionId}/review', [\Modules\Deductions\Controllers\DeductionController::class, 'reviewDecision']);
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class] Router::post('/deductions/{deductionId}/admin-review', [\Modules\Deductions\Controllers\DeductionController::class, 'adminReview']);
], function ($router) {
$router->get('/', Modules\Deductions\Controllers\DeductionController::class, 'index');
$router->get('/{deductionId}', Modules\Deductions\Controllers\DeductionController::class, 'show');
$router->post('/', Modules\Deductions\Controllers\DeductionController::class, 'initiate');
$router->post('/{deductionId}/acknowledge', Modules\Deductions\Controllers\DeductionController::class, 'acknowledge');
$router->post('/{deductionId}/respond', Modules\Deductions\Controllers\DeductionController::class, 'respond');
$router->post('/{deductionId}/review-decision', Modules\Deductions\Controllers\DeductionController::class, 'reviewDecision');
$router->post('/{deductionId}/admin-review', Modules\Deductions\Controllers\DeductionController::class, 'adminReview');
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
use Modules\Evaluations\Controllers\EvaluationController; Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
use Modules\Evaluations\Controllers\EvaluationCycleController; Router::get('/evaluations', [\Modules\Evaluations\Controllers\EvaluationController::class, 'myEvaluations']);
Router::get('/evaluations/pending', [\Modules\Evaluations\Controllers\EvaluationController::class, 'pending']);
return function (Router $router) { Router::get('/evaluations/compiled/{compiledId}', [\Modules\Evaluations\Controllers\EvaluationController::class, 'showCompiled']);
$router->get('/evaluations/mine', [EvaluationController::class, 'myEvaluations']); Router::get('/evaluations/{evaluationId}/technical', [\Modules\Evaluations\Controllers\EvaluationController::class, 'technicalForm']);
$router->get('/evaluations/pending', [EvaluationController::class, 'pending']); Router::post('/evaluations/{evaluationId}/technical', [\Modules\Evaluations\Controllers\EvaluationController::class, 'submitTechnical']);
$router->get('/evaluations/compiled/{compiledId}', [EvaluationController::class, 'showCompiled']); Router::get('/evaluations/{evaluationId}/professional', [\Modules\Evaluations\Controllers\EvaluationController::class, 'professionalForm']);
$router->get('/evaluations/{evaluationId}/technical', [EvaluationController::class, 'technicalForm']); Router::post('/evaluations/{evaluationId}/professional', [\Modules\Evaluations\Controllers\EvaluationController::class, 'submitProfessional']);
$router->post('/evaluations/{evaluationId}/technical', [EvaluationController::class, 'submitTechnical']); Router::post('/evaluations/compiled/{compiledId}/acknowledge', [\Modules\Evaluations\Controllers\EvaluationController::class, 'acknowledge']);
$router->get('/evaluations/{evaluationId}/professional', [EvaluationController::class, 'professionalForm']); Router::post('/evaluations/compiled/{compiledId}/respond', [\Modules\Evaluations\Controllers\EvaluationController::class, 'respond']);
$router->post('/evaluations/{evaluationId}/professional', [EvaluationController::class, 'submitProfessional']); Router::get('/evaluations/cycles', [\Modules\Evaluations\Controllers\EvaluationCycleController::class, 'index']);
$router->post('/evaluations/compiled/{compiledId}/acknowledge', [EvaluationController::class, 'acknowledge']); Router::get('/evaluations/cycles/{cycleId}', [\Modules\Evaluations\Controllers\EvaluationCycleController::class, 'show']);
$router->post('/evaluations/compiled/{compiledId}/respond', [EvaluationController::class, 'respond']); Router::post('/evaluations/cycles', [\Modules\Evaluations\Controllers\EvaluationCycleController::class, 'create']);
});
$router->get('/evaluations/cycles', [EvaluationCycleController::class, 'index']); \ No newline at end of file
$router->get('/evaluations/cycles/{cycleId}', [EvaluationCycleController::class, 'show']);
$router->post('/evaluations/cycles', [EvaluationCycleController::class, 'create']);
// API mirrors
$router->get('/api/evaluations', [EvaluationController::class, 'myEvaluations']);
$router->get('/api/evaluations/pending', [EvaluationController::class, 'pending']);
$router->get('/api/evaluations/compiled/{compiledId}', [EvaluationController::class, 'showCompiled']);
$router->post('/api/evaluations/{evaluationId}/technical', [EvaluationController::class, 'submitTechnical']);
$router->post('/api/evaluations/{evaluationId}/professional', [EvaluationController::class, 'submitProfessional']);
$router->post('/api/evaluations/compiled/{compiledId}/acknowledge', [EvaluationController::class, 'acknowledge']);
$router->post('/api/evaluations/compiled/{compiledId}/respond', [EvaluationController::class, 'respond']);
$router->get('/api/evaluations/cycles', [EvaluationCycleController::class, 'index']);
$router->get('/api/evaluations/cycles/{cycleId}', [EvaluationCycleController::class, 'show']);
$router->post('/api/evaluations/cycles', [EvaluationCycleController::class, 'create']);
};
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::group('/export', ['middleware' => ['auth', 'audit']], function () { Router::post('/export/csv', [\Modules\Export\Controllers\ExportController::class, 'exportCsv']);
Router::post('/csv', [\Modules\Export\Controllers\ExportController::class, 'exportCsv']); Router::get('/export/contractor/{userId}', [\Modules\Export\Controllers\ExportController::class, 'exportContractorZip']);
Router::get('/contractor/{userId}', [\Modules\Export\Controllers\ExportController::class, 'exportContractorZip']); Router::post('/export/audit-trail', [\Modules\Export\Controllers\ExportController::class, 'exportAuditTrail']);
Router::post('/audit-trail', [\Modules\Export\Controllers\ExportController::class, 'exportAuditTrail']); Router::get('/export/payslip/{payrollId}', [\Modules\Export\Controllers\PdfExportController::class, 'payslip']);
Router::get('/payslip/{payrollId}', [\Modules\Export\Controllers\PdfExportController::class, 'payslip']); Router::get('/export/evaluation/{compiledId}', [\Modules\Export\Controllers\PdfExportController::class, 'evaluationReport']);
Router::get('/evaluation/{compiledId}', [\Modules\Export\Controllers\PdfExportController::class, 'evaluationReport']);
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
use Modules\Holidays\Controllers\HolidayController; Router::group('', ['middleware' => ['auth']], function () {
Router::get('/holidays', [\Modules\Holidays\Controllers\HolidayController::class, 'index']);
return function (Router $router) { Router::post('/holidays', [\Modules\Holidays\Controllers\HolidayController::class, 'create']);
$router->get('/holidays', [HolidayController::class, 'index']); Router::put('/holidays/{id}', [\Modules\Holidays\Controllers\HolidayController::class, 'update']);
$router->post('/holidays', [HolidayController::class, 'create']); Router::delete('/holidays/{id}', [\Modules\Holidays\Controllers\HolidayController::class, 'delete']);
$router->put('/holidays/{id}', [HolidayController::class, 'update']); });
$router->delete('/holidays/{id}', [HolidayController::class, 'delete']); \ No newline at end of file
$router->get('/api/holidays', [HolidayController::class, 'index']);
$router->post('/api/holidays', [HolidayController::class, 'create']);
$router->put('/api/holidays/{id}', [HolidayController::class, 'update']);
$router->delete('/api/holidays/{id}', [HolidayController::class, 'delete']);
};
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
use Engine\Core\Container; Router::get('/labels', [\Modules\Labels\Controllers\LabelController::class, 'index']);
Router::post('/labels', [\Modules\Labels\Controllers\LabelController::class, 'create']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class); Router::put('/labels/{labelId}', [\Modules\Labels\Controllers\LabelController::class, 'update']);
Router::delete('/labels/{labelId}', [\Modules\Labels\Controllers\LabelController::class, 'delete']);
$router->group([
'prefix' => '/api/labels',
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class]
], function ($router) {
$router->get('/', Modules\Labels\Controllers\LabelController::class, 'index');
$router->post('/', Modules\Labels\Controllers\LabelController::class, 'create');
$router->post('/{labelId}', Modules\Labels\Controllers\LabelController::class, 'update');
$router->post('/{labelId}/delete', Modules\Labels\Controllers\LabelController::class, 'delete');
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
use Modules\LearningGoals\Controllers\LearningGoalController; Router::group('', ['middleware' => ['auth']], function () {
use Modules\LearningGoals\Controllers\CompetencyController; Router::get('/learning-goals', [\Modules\LearningGoals\Controllers\LearningGoalController::class, 'index']);
Router::post('/learning-goals', [\Modules\LearningGoals\Controllers\LearningGoalController::class, 'create']);
return function (Router $router) { Router::put('/learning-goals/{goalId}', [\Modules\LearningGoals\Controllers\LearningGoalController::class, 'update']);
$router->get('/learning-goals', [LearningGoalController::class, 'index']); Router::post('/learning-goals/{goalId}/assess', [\Modules\LearningGoals\Controllers\LearningGoalController::class, 'assess']);
$router->post('/learning-goals', [LearningGoalController::class, 'create']); Router::delete('/learning-goals/{goalId}', [\Modules\LearningGoals\Controllers\LearningGoalController::class, 'delete']);
$router->put('/learning-goals/{goalId}', [LearningGoalController::class, 'update']); Router::get('/competencies', [\Modules\LearningGoals\Controllers\CompetencyController::class, 'areas']);
$router->post('/learning-goals/{goalId}/assess', [LearningGoalController::class, 'assess']); Router::get('/competencies/{userId}/profile', [\Modules\LearningGoals\Controllers\CompetencyController::class, 'profile']);
$router->delete('/learning-goals/{goalId}', [LearningGoalController::class, 'delete']); Router::post('/competencies/{userId}/assess', [\Modules\LearningGoals\Controllers\CompetencyController::class, 'submitAssessment']);
});
$router->get('/competency/areas', [CompetencyController::class, 'areas']); \ No newline at end of file
$router->get('/competency/profile/{userId}', [CompetencyController::class, 'profile']);
$router->post('/competency/assess/{userId}', [CompetencyController::class, 'submitAssessment']);
$router->get('/api/learning-goals', [LearningGoalController::class, 'index']);
$router->post('/api/learning-goals', [LearningGoalController::class, 'create']);
$router->put('/api/learning-goals/{goalId}', [LearningGoalController::class, 'update']);
$router->put('/api/learning-goals/{goalId}/assess', [LearningGoalController::class, 'assess']);
$router->delete('/api/learning-goals/{goalId}', [LearningGoalController::class, 'delete']);
$router->get('/api/competency/areas', [CompetencyController::class, 'areas']);
$router->get('/api/competency/profile/{userId}', [CompetencyController::class, 'profile']);
$router->post('/api/competency/assess/{userId}', [CompetencyController::class, 'submitAssessment']);
};
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
use Modules\Meetings\Controllers\MeetingController; Router::group('', ['middleware' => ['auth']], function () {
Router::get('/meetings', [\Modules\Meetings\Controllers\MeetingController::class, 'index']);
return function (Router $router) { Router::get('/meetings/{meetingId}', [\Modules\Meetings\Controllers\MeetingController::class, 'show']);
$router->get('/meetings', [MeetingController::class, 'index']); Router::post('/meetings', [\Modules\Meetings\Controllers\MeetingController::class, 'create']);
$router->get('/meetings/{meetingId}', [MeetingController::class, 'show']); Router::put('/meetings/{meetingId}', [\Modules\Meetings\Controllers\MeetingController::class, 'update']);
$router->post('/meetings', [MeetingController::class, 'create']); Router::post('/meetings/{meetingId}/notes', [\Modules\Meetings\Controllers\MeetingController::class, 'addNotes']);
$router->put('/meetings/{meetingId}', [MeetingController::class, 'update']); Router::delete('/meetings/{meetingId}', [\Modules\Meetings\Controllers\MeetingController::class, 'delete']);
$router->post('/meetings/{meetingId}/notes', [MeetingController::class, 'addNotes']); });
$router->delete('/meetings/{meetingId}', [MeetingController::class, 'delete']); \ No newline at end of file
$router->get('/api/meetings', [MeetingController::class, 'index']);
$router->get('/api/meetings/{meetingId}', [MeetingController::class, 'show']);
$router->post('/api/meetings', [MeetingController::class, 'create']);
$router->put('/api/meetings/{meetingId}', [MeetingController::class, 'update']);
$router->post('/api/meetings/{meetingId}/notes', [MeetingController::class, 'addNotes']);
$router->delete('/api/meetings/{meetingId}', [MeetingController::class, 'delete']);
};
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
use Engine\Core\Container; Router::get('/messages', [\Modules\Messaging\Controllers\MessagingController::class, 'conversations']);
Router::get('/messages/{conversationId}', [\Modules\Messaging\Controllers\MessagingController::class, 'messages']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class); Router::post('/messages/conversations', [\Modules\Messaging\Controllers\MessagingController::class, 'startConversation']);
Router::post('/messages/{conversationId}', [\Modules\Messaging\Controllers\MessagingController::class, 'sendMessage']);
$router->group([
'prefix' => '/messages',
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class, Middleware\BlockingNotificationMiddleware::class]
], function ($router) {
$router->get('/', Modules\Messaging\Controllers\MessagingController::class, 'conversations');
$router->post('/', Modules\Messaging\Controllers\MessagingController::class, 'startConversation');
$router->get('/{conversationId}', Modules\Messaging\Controllers\MessagingController::class, 'messages');
$router->post('/{conversationId}', Modules\Messaging\Controllers\MessagingController::class, 'sendMessage');
}); });
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
use Engine\Core\Container; Router::get('/notifications', [\Modules\Notifications\Controllers\NotificationController::class, 'index']);
Router::get('/notifications/recent', [\Modules\Notifications\Controllers\NotificationController::class, 'recent']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class); Router::get('/notifications/blocking', [\Modules\Notifications\Controllers\NotificationController::class, 'showBlocking']);
Router::post('/notifications/{id}/read', [\Modules\Notifications\Controllers\NotificationController::class, 'markRead']);
$router->group([ Router::post('/notifications/read-all', [\Modules\Notifications\Controllers\NotificationController::class, 'markAllRead']);
'prefix' => '/notifications', Router::post('/notifications/{id}/acknowledge', [\Modules\Notifications\Controllers\NotificationController::class, 'acknowledge']);
'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 <?php
use Engine\Core\Router; use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::group('/offboarding', ['middleware' => ['auth', 'csrf']], function () { Router::post('/offboarding', [\Modules\Offboarding\Controllers\OffboardingController::class, 'initiate']);
Router::post('/terminate', [\Modules\Offboarding\Controllers\OffboardingController::class, 'initiate']); Router::get('/offboarding/{userId}/settlement', [\Modules\Offboarding\Controllers\OffboardingController::class, 'calculateFinalSettlement']);
Router::get('/settlement/{userId}', [\Modules\Offboarding\Controllers\OffboardingController::class, 'calculateFinalSettlement']);
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::group('/invites', ['middleware' => ['auth', 'csrf']], function () { Router::get('/invites', [\Modules\Onboarding\Controllers\InviteController::class, 'index']);
Router::get('', [\Modules\Onboarding\Controllers\InviteController::class, 'index']); Router::post('/invites', [\Modules\Onboarding\Controllers\InviteController::class, 'create']);
Router::post('', [\Modules\Onboarding\Controllers\InviteController::class, 'create']); Router::post('/invites/{inviteId}/revoke', [\Modules\Onboarding\Controllers\InviteController::class, 'revoke']);
Router::post('/{inviteId}/revoke', [\Modules\Onboarding\Controllers\InviteController::class, 'revoke']); Router::delete('/invites/{inviteId}', [\Modules\Onboarding\Controllers\InviteController::class, 'delete']);
Router::delete('/{inviteId}', [\Modules\Onboarding\Controllers\InviteController::class, 'delete']);
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
use Modules\PIPs\Controllers\PIPController; Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
Router::get('/pips', [\Modules\PIPs\Controllers\PIPController::class, 'index']);
return function (Router $router) { Router::get('/pips/{pipId}', [\Modules\PIPs\Controllers\PIPController::class, 'show']);
$router->get('/pips', [PIPController::class, 'index']); Router::post('/pips', [\Modules\PIPs\Controllers\PIPController::class, 'create']);
$router->get('/pips/{pipId}', [PIPController::class, 'show']); Router::post('/pips/{pipId}/acknowledge', [\Modules\PIPs\Controllers\PIPController::class, 'acknowledge']);
$router->post('/pips', [PIPController::class, 'create']); Router::post('/pips/{pipId}/checkins/{checkinId}', [\Modules\PIPs\Controllers\PIPController::class, 'logCheckin']);
$router->post('/pips/{pipId}/acknowledge', [PIPController::class, 'acknowledge']); Router::post('/pips/{pipId}/decide', [\Modules\PIPs\Controllers\PIPController::class, 'decide']);
$router->post('/pips/{pipId}/checkins/{checkinId}', [PIPController::class, 'logCheckin']); Router::delete('/pips/{pipId}', [\Modules\PIPs\Controllers\PIPController::class, 'delete']);
$router->post('/pips/{pipId}/decide', [PIPController::class, 'decide']); });
$router->delete('/pips/{pipId}', [PIPController::class, 'delete']); \ No newline at end of file
$router->get('/api/pips', [PIPController::class, 'index']);
$router->get('/api/pips/{pipId}', [PIPController::class, 'show']);
$router->post('/api/pips', [PIPController::class, 'create']);
$router->post('/api/pips/{pipId}/acknowledge', [PIPController::class, 'acknowledge']);
$router->post('/api/pips/{pipId}/checkin', [PIPController::class, 'logCheckin']);
$router->put('/api/pips/{pipId}/result', [PIPController::class, 'decide']);
$router->delete('/api/pips/{pipId}', [PIPController::class, 'delete']);
};
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
use Engine\Core\Container; Router::get('/payroll', [\Modules\Payroll\Controllers\PayrollController::class, 'index']);
Router::post('/payroll/calculate', [\Modules\Payroll\Controllers\PayrollController::class, 'calculate']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class); Router::post('/payroll/{payrollId}/submit', [\Modules\Payroll\Controllers\PayrollController::class, 'submit']);
Router::post('/payroll/{payrollId}/approve', [\Modules\Payroll\Controllers\PayrollController::class, 'approve']);
$router->group([ Router::post('/payroll/{payrollId}/reject', [\Modules\Payroll\Controllers\PayrollController::class, 'reject']);
'prefix' => '/payroll', Router::post('/payroll/{payrollId}/paid', [\Modules\Payroll\Controllers\PayrollController::class, 'markPaid']);
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class]
], function ($router) {
$router->get('/', Modules\Payroll\Controllers\PayrollController::class, 'index');
$router->post('/calculate', Modules\Payroll\Controllers\PayrollController::class, 'calculate');
$router->post('/{payrollId}/submit', Modules\Payroll\Controllers\PayrollController::class, 'submit');
$router->post('/{payrollId}/approve', Modules\Payroll\Controllers\PayrollController::class, 'approve');
$router->post('/{payrollId}/reject', Modules\Payroll\Controllers\PayrollController::class, 'reject');
$router->post('/{payrollId}/paid', Modules\Payroll\Controllers\PayrollController::class, 'markPaid');
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
use Modules\RecurringCards\Controllers\RecurringCardController; Router::group('', ['middleware' => ['auth']], function () {
Router::get('/recurring-cards', [\Modules\RecurringCards\Controllers\RecurringCardController::class, 'index']);
return function (Router $router) { Router::post('/recurring-cards', [\Modules\RecurringCards\Controllers\RecurringCardController::class, 'create']);
$router->get('/recurring-cards', [RecurringCardController::class, 'index']); Router::put('/recurring-cards/{defId}', [\Modules\RecurringCards\Controllers\RecurringCardController::class, 'update']);
$router->post('/recurring-cards', [RecurringCardController::class, 'create']); Router::delete('/recurring-cards/{defId}', [\Modules\RecurringCards\Controllers\RecurringCardController::class, 'delete']);
$router->put('/recurring-cards/{defId}', [RecurringCardController::class, 'update']); });
$router->delete('/recurring-cards/{defId}', [RecurringCardController::class, 'delete']); \ No newline at end of file
$router->get('/api/recurring-cards', [RecurringCardController::class, 'index']);
$router->post('/api/recurring-cards', [RecurringCardController::class, 'create']);
$router->put('/api/recurring-cards/{defId}', [RecurringCardController::class, 'update']);
$router->delete('/api/recurring-cards/{defId}', [RecurringCardController::class, 'delete']);
};
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
use Engine\Core\Container; Router::get('/reports/submit', [\Modules\Reports\Controllers\ReportController::class, 'submitForm']);
Router::post('/reports/submit', [\Modules\Reports\Controllers\ReportController::class, 'submit']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class); Router::get('/reports/review', [\Modules\Reports\Controllers\ReportController::class, 'review']);
Router::post('/reports/{reportId}/review', [\Modules\Reports\Controllers\ReportController::class, 'reviewAction']);
$router->group([ Router::post('/reports/bulk-approve', [\Modules\Reports\Controllers\ReportController::class, 'bulkApprove']);
'prefix' => '/reports', Router::get('/reports/history', [\Modules\Reports\Controllers\ReportController::class, 'history']);
'middleware' => [
Middleware\AuthenticationMiddleware::class,
Middleware\CSRFMiddleware::class,
Middleware\BlockingNotificationMiddleware::class,
]
], function ($router) {
$router->get('/submit', Modules\Reports\Controllers\ReportController::class, 'submitForm');
$router->post('/submit', Modules\Reports\Controllers\ReportController::class, 'submit');
$router->get('/review', Modules\Reports\Controllers\ReportController::class, 'review');
$router->post('/{reportId}/review', Modules\Reports\Controllers\ReportController::class, 'reviewAction');
$router->post('/bulk-approve', Modules\Reports\Controllers\ReportController::class, 'bulkApprove');
$router->get('/history', Modules\Reports\Controllers\ReportController::class, 'history');
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
use Modules\Dashboard\Controllers\DashboardController; // Salary calculations are accessed via API and calculator engine, no standalone routes needed.
\ No newline at end of file
return function (Router $router) {
// HUD data is served via the Dashboard controller's getHudData
// and SSE stream. Salary module routes for API access:
$router->get('/api/users/{userId}/hud', function (\Engine\Core\Request $request, string $userId) {
$user = $request->user();
$db = \Engine\Core\Container::getInstance()->resolve(\Engine\Database\Connection::class);
$targetId = (int)$userId;
if ($user['role'] === 'contractor' && $user['id'] !== $targetId) {
return \Engine\Core\Response::json(['error' => 'Forbidden'], 403);
}
$target = $db->fetchOne("SELECT * FROM users WHERE id = ?", [$targetId]);
if (!$target) {
return \Engine\Core\Response::json(['error' => 'User not found'], 404);
}
$month = date('Y-m');
$actualSalary = (float)($target['actual_salary'] ?? 0);
$totalBounties = (float)$db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?",
[$targetId, $month]
);
$totalDeductions = (float)$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",
[$targetId, $month]
);
$totalPosAdj = (float)$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",
[$targetId, $month]
);
$totalNegAdj = (float)$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",
[$targetId, $month]
);
$liveSalary = $actualSalary + $totalBounties + $totalPosAdj - $totalDeductions - $totalNegAdj;
$retentionPct = $actualSalary > 0 ? ($liveSalary / $actualSalary) * 100 : 100;
return \Engine\Core\Response::json([
'actual_salary' => $actualSalary,
'live_salary' => round($liveSalary, 2),
'total_bounties' => $totalBounties,
'total_deductions' => $totalDeductions,
'total_pos_adj' => $totalPosAdj,
'total_neg_adj' => $totalNegAdj,
'retention_pct' => round($retentionPct, 2),
'month' => $month,
]);
});
};
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::group('/saved-filters', ['middleware' => ['auth', 'csrf']], function () { Router::get('/saved-filters', [\Modules\SavedFilters\Controllers\SavedFilterController::class, 'index']);
Router::get('', [\Modules\SavedFilters\Controllers\SavedFilterController::class, 'index']); Router::post('/saved-filters', [\Modules\SavedFilters\Controllers\SavedFilterController::class, 'create']);
Router::post('', [\Modules\SavedFilters\Controllers\SavedFilterController::class, 'create']); Router::delete('/saved-filters/{filterId}', [\Modules\SavedFilters\Controllers\SavedFilterController::class, 'delete']);
Router::delete('/{filterId}', [\Modules\SavedFilters\Controllers\SavedFilterController::class, 'delete']);
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
use Modules\Schedules\Controllers\ScheduleController; Router::group('', ['middleware' => ['auth']], function () {
Router::get('/schedules/{userId}', [\Modules\Schedules\Controllers\ScheduleController::class, 'currentSchedule']);
return function (Router $router) { Router::get('/schedules/requests', [\Modules\Schedules\Controllers\ScheduleController::class, 'requests']);
$router->get('/schedules/user/{userId}', [ScheduleController::class, 'currentSchedule']); Router::post('/schedules/requests', [\Modules\Schedules\Controllers\ScheduleController::class, 'submitRequest']);
$router->get('/schedules/requests', [ScheduleController::class, 'requests']); Router::post('/schedules/requests/{requestId}/review', [\Modules\Schedules\Controllers\ScheduleController::class, 'reviewRequest']);
$router->post('/schedules/requests', [ScheduleController::class, 'submitRequest']); Router::post('/schedules/{userId}/direct', [\Modules\Schedules\Controllers\ScheduleController::class, 'directEdit']);
$router->post('/schedules/requests/{requestId}/review', [ScheduleController::class, 'reviewRequest']); });
$router->put('/schedules/user/{userId}/direct', [ScheduleController::class, 'directEdit']); \ No newline at end of file
$router->get('/api/users/{userId}/schedule', [ScheduleController::class, 'currentSchedule']);
$router->get('/api/schedule-requests', [ScheduleController::class, 'requests']);
$router->post('/api/schedule-requests', [ScheduleController::class, 'submitRequest']);
$router->post('/api/schedule-requests/{requestId}/review', [ScheduleController::class, 'reviewRequest']);
$router->put('/api/users/{userId}/schedule', [ScheduleController::class, 'directEdit']);
};
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
use Engine\Core\Container; Router::get('/search', [\Modules\Search\Controllers\SearchController::class, 'search']);
});
$router = Container::getInstance()->resolve(Engine\Core\Router::class); \ No newline at end of file
$router->get('/api/search', Modules\Search\Controllers\SearchController::class, 'search')
->middleware([Middleware\AuthenticationMiddleware::class]);
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::group('/session-management', ['middleware' => ['auth', 'audit']], function () { Router::get('/session-management', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'allSessions']);
Router::get('/sessions', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'allSessions']); Router::delete('/session-management/{sessionId}', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'killSession']);
Router::delete('/sessions/{sessionId}', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'killSession']); Router::delete('/session-management/user/{userId}', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'killAllForUser']);
Router::delete('/users/{userId}/sessions', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'killAllForUser']); Router::post('/session-management/kill-all', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'killAll']);
Router::delete('/sessions-all', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'killAll']); Router::get('/session-management/login-history', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'loginHistory']);
Router::get('/login-history', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'loginHistory']);
}); });
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
use Engine\Core\Container; Router::get('/settings', [\Modules\Settings\Controllers\SettingsController::class, 'index']);
Router::put('/settings', [\Modules\Settings\Controllers\SettingsController::class, 'update']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/settings',
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class]
], function ($router) {
$router->get('/', Modules\Settings\Controllers\SettingsController::class, 'index');
$router->post('/', Modules\Settings\Controllers\SettingsController::class, 'update');
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::group('/system-health', ['middleware' => ['auth', 'audit']], function () { Router::get('/system-health', [\Modules\SystemHealth\Controllers\SystemHealthController::class, 'index']);
Router::get('/', [\Modules\SystemHealth\Controllers\SystemHealthController::class, 'index']);
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
use Modules\TeamAvailability\Controllers\TeamAvailabilityController; Router::group('', ['middleware' => ['auth']], function () {
Router::get('/team-availability', [\Modules\TeamAvailability\Controllers\TeamAvailabilityController::class, 'index']);
return function (Router $router) { });
$router->get('/team-availability', [TeamAvailabilityController::class, 'index']); \ No newline at end of file
$router->get('/api/team-availability', [TeamAvailabilityController::class, 'index']);
};
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
use Modules\Unavailability\Controllers\UnavailabilityController; Router::group('', ['middleware' => ['auth']], function () {
Router::get('/unavailability', [\Modules\Unavailability\Controllers\UnavailabilityController::class, 'index']);
return function (Router $router) { Router::post('/unavailability', [\Modules\Unavailability\Controllers\UnavailabilityController::class, 'create']);
$router->get('/unavailability', [UnavailabilityController::class, 'index']); Router::put('/unavailability/{id}', [\Modules\Unavailability\Controllers\UnavailabilityController::class, 'update']);
$router->post('/unavailability', [UnavailabilityController::class, 'create']); Router::delete('/unavailability/{id}', [\Modules\Unavailability\Controllers\UnavailabilityController::class, 'delete']);
$router->put('/unavailability/{id}', [UnavailabilityController::class, 'update']); });
$router->delete('/unavailability/{id}', [UnavailabilityController::class, 'delete']); \ No newline at end of file
$router->get('/api/unavailability', [UnavailabilityController::class, 'index']);
$router->post('/api/unavailability', [UnavailabilityController::class, 'create']);
$router->put('/api/unavailability/{id}', [UnavailabilityController::class, 'update']);
$router->delete('/api/unavailability/{id}', [UnavailabilityController::class, 'delete']);
};
\ No newline at end of file
<?php <?php
declare(strict_types=1); use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
use Engine\Core\Container; Router::get('/users', [\Modules\Users\Controllers\UserController::class, 'directory']);
Router::get('/users/{userId}', [\Modules\Users\Controllers\UserController::class, 'show']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class); Router::put('/users/{userId}', [\Modules\Users\Controllers\UserController::class, 'update']);
Router::post('/users/{userId}/salary', [\Modules\Users\Controllers\UserController::class, 'setSalary']);
$router->group([ Router::post('/users/{userId}/status', [\Modules\Users\Controllers\UserController::class, 'changeStatus']);
'prefix' => '/users', Router::get('/users/{userId}/notes', [\Modules\Users\Controllers\UserController::class, 'privateNotes']);
'middleware' => [ Router::post('/users/{userId}/notes', [\Modules\Users\Controllers\UserController::class, 'addPrivateNote']);
Middleware\AuthenticationMiddleware::class, Router::get('/users/{userId}/sessions', [\Modules\Users\Controllers\UserController::class, 'sessions']);
Middleware\CSRFMiddleware::class, Router::post('/users/{userId}/force-logout', [\Modules\Users\Controllers\UserController::class, 'forceLogout']);
Middleware\BlockingNotificationMiddleware::class, Router::get('/users/{userId}/salary-history', [\Modules\Users\Controllers\UserController::class, 'salaryHistory']);
]
], function ($router) {
$router->get('/', Modules\Users\Controllers\UserController::class, 'directory');
$router->get('/{userId}', Modules\Users\Controllers\UserController::class, 'show');
$router->post('/{userId}', Modules\Users\Controllers\UserController::class, 'update');
$router->post('/{userId}/salary', Modules\Users\Controllers\UserController::class, 'setSalary');
$router->post('/{userId}/status', Modules\Users\Controllers\UserController::class, 'changeStatus');
$router->get('/{userId}/notes', Modules\Users\Controllers\UserController::class, 'privateNotes');
$router->post('/{userId}/notes', Modules\Users\Controllers\UserController::class, 'addPrivateNote');
$router->get('/{userId}/sessions', Modules\Users\Controllers\UserController::class, 'sessions');
$router->post('/{userId}/force-logout', Modules\Users\Controllers\UserController::class, 'forceLogout');
$router->get('/{userId}/salary-history', Modules\Users\Controllers\UserController::class, 'salaryHistory');
}); });
\ No newline at end of file
<?php <?php
use Engine\Core\Router; use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::group('/webhooks', ['middleware' => ['auth', 'audit']], function () { Router::get('/webhooks', [\Modules\Webhooks\Controllers\WebhookController::class, 'index']);
Router::get('/', [\Modules\Webhooks\Controllers\WebhookController::class, 'index']); Router::post('/webhooks', [\Modules\Webhooks\Controllers\WebhookController::class, 'create']);
Router::post('/', [\Modules\Webhooks\Controllers\WebhookController::class, 'create']); Router::put('/webhooks/{webhookId}', [\Modules\Webhooks\Controllers\WebhookController::class, 'update']);
Router::put('/{webhookId}', [\Modules\Webhooks\Controllers\WebhookController::class, 'update']); Router::post('/webhooks/{webhookId}/test', [\Modules\Webhooks\Controllers\WebhookController::class, 'test']);
Router::post('/{webhookId}/test', [\Modules\Webhooks\Controllers\WebhookController::class, 'test']); Router::get('/webhooks/{webhookId}/deliveries', [\Modules\Webhooks\Controllers\WebhookController::class, 'deliveries']);
Router::get('/{webhookId}/deliveries', [\Modules\Webhooks\Controllers\WebhookController::class, 'deliveries']); Router::delete('/webhooks/{webhookId}', [\Modules\Webhooks\Controllers\WebhookController::class, 'delete']);
Router::delete('/{webhookId}', [\Modules\Webhooks\Controllers\WebhookController::class, 'delete']);
}); });
\ No newline at end of file
<?php <?php
declare(strict_types=1); declare(strict_types=1);
// FORCE error display — remove after debugging // ─── BOOTSTRAP ───
error_reporting(E_ALL); $container = require __DIR__ . '/../bootstrap/app.php';
ini_set('display_errors', '1');
ini_set('log_errors', '1');
define('ROOT_PATH', dirname(__DIR__)); use Engine\Core\{Router, Request, Response};
require ROOT_PATH . '/bootstrap/autoload.php'; // ─── HANDLE REQUEST ───
$request = new Request();
$router = $container->resolve(Router::class);
try { try {
require ROOT_PATH . '/bootstrap/app.php'; $response = $router->dispatch($request);
} catch (\Engine\Auth\ForbiddenException $e) {
$app = \Engine\Core\Container::getInstance()->resolve(\Engine\Core\App::class); $response = $request->wantsJson()
$app->run(); ? Response::json(['error' => 'Forbidden'], 403)
: Response::html('<h1>403 Forbidden</h1>', 403);
} catch (\Throwable $e) { } catch (\Throwable $e) {
http_response_code(500); error_log("REQUEST ERROR: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}\n{$e->getTraceAsString()}");
header('Content-Type: text/html; charset=utf-8'); $response = $request->wantsJson()
echo '<!DOCTYPE html><html><head><title>500 Error</title>'; ? Response::json(['error' => 'Internal server error'], 500)
echo '<style>body{font-family:monospace;padding:40px;background:#0f172a;color:#e2e8f0}h1{color:#ef4444}pre{background:#1e293b;padding:20px;border-radius:8px;overflow-x:auto;white-space:pre-wrap}</style>'; : Response::html(file_get_contents(ROOT_PATH . '/templates/errors/500.php') ?: '<h1>500</h1>', 500);
echo '</head><body>'; }
echo '<h1>500 — Bootstrap Failed</h1>';
echo '<pre>'; $response->send();
echo htmlspecialchars($e->getMessage()) . "\n\n"; \ No newline at end of file
echo "File: " . htmlspecialchars($e->getFile()) . ":" . $e->getLine() . "\n\n";
echo htmlspecialchars($e->getTraceAsString());
echo '</pre>';
echo '<p style="margin-top:20px;color:#94a3b8">This error display is temporary. Remove display_errors after fixing.</p>';
echo '</body></html>';
}
\ No newline at end of file
<?php $__engine->extend('layouts/auth'); ?> <?php /** @var string|null $error */ ?>
<div class="auth-box">
<h1 class="auth-title">🎮 The Grind</h1>
<p style="text-align:center;color:var(--text-secondary);margin-bottom:24px;">AL-ARCADE HR Platform</p>
<?php $__engine->section('title'); ?>Login<?php $__engine->endSection(); ?> <?php if (!empty($error)): ?>
<div class="auth-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if (!empty($error)): ?> <form method="POST" action="/login">
<div class="alert alert-error"><?= $__engine->e($error) ?></div> <div class="form-group">
<?php endif; ?> <label class="form-label">Username</label>
<input type="text" name="username" class="form-control" required autofocus autocomplete="username">
<form method="POST" action="/login" class="auth-form"> </div>
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>"> <div class="form-group">
<label class="form-label">Password</label>
<h2>Sign In</h2> <input type="password" name="password" class="form-control" required autocomplete="current-password">
</div>
<div class="form-group"> <button type="submit" class="btn btn-primary btn-block btn-lg" style="margin-top:8px;">Login</button>
<label for="username">Username</label> </form>
<input type="text" id="username" name="username" required autofocus autocomplete="username"> </div>
</div> \ No newline at end of file
<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
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