Commit 96db1143 authored by Administrator's avatar Administrator

Update 84 files via Son of Anton

parent 223727b5
Pipeline #25 canceled with stage
<?php
declare(strict_types=1);
use Engine\Core\App;
use Engine\Core\Config;
use Engine\Core\Container;
use Engine\Core\Router;
require_once __DIR__ . '/autoload.php';
use Engine\Core\{Container, Config, Router};
use Engine\Database\Connection;
use Engine\Auth\SessionManager;
use Engine\Auth\Authenticator;
use Engine\Auth\PasswordHasher;
use Engine\Auth\PermissionEngine;
use Engine\Auth\RateLimiter;
use Engine\Auth\{SessionManager, Authenticator, PasswordHasher, PermissionEngine, RateLimiter};
use Engine\Audit\AuditLogger;
use Engine\Events\EventDispatcher;
use Engine\StateMachine\StateMachine;
use Engine\Notifications\NotificationManager;
use Engine\Calculation\CalculationEngine;
use Engine\Events\EventDispatcher;
use Engine\Validation\Validator;
use Engine\FileStorage\FileManager;
use Engine\Scheduler\JobRunner;
use Engine\Calculation\CalculationEngine;
use Engine\Template\TemplateEngine;
use Engine\Search\SearchEngine;
use Engine\FileStorage\FileManager;
use Engine\Export\ExportManager;
use Engine\Template\TemplateEngine;
$container = Container::getInstance();
// Config
$container->singleton(Config::class, function () {
return new Config(ROOT_PATH . '/config');
});
// Database
$container->singleton(Connection::class, function () use ($container) {
$cfg = $container->resolve(Config::class)->get('database');
return new Connection($cfg);
});
// Template Engine
$container->singleton(TemplateEngine::class, function () {
return new TemplateEngine(ROOT_PATH . '/templates', ROOT_PATH . '/storage/cache/templates');
});
// Password Hasher
$container->singleton(PasswordHasher::class, fn() => new PasswordHasher(12));
// Session Manager
$container->singleton(SessionManager::class, function () use ($container) {
return new SessionManager($container->resolve(Connection::class));
});
use Engine\Cache\QueryCache;
use Engine\Scheduler\JobRunner;
// Rate Limiter
$container->singleton(RateLimiter::class, function () use ($container) {
return new RateLimiter($container->resolve(Connection::class));
});
// Timezone
date_default_timezone_set('Africa/Cairo');
// Authenticator
$container->singleton(Authenticator::class, function () use ($container) {
return new Authenticator(
$container->resolve(Connection::class),
$container->resolve(PasswordHasher::class),
$container->resolve(SessionManager::class),
$container->resolve(RateLimiter::class)
);
// Error handling
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) {
throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
});
// Permission Engine
$container->singleton(PermissionEngine::class, function () use ($container) {
$perms = $container->resolve(Config::class)->get('permissions');
return new PermissionEngine($perms, $container->resolve(Connection::class));
set_exception_handler(function (\Throwable $e) {
error_log("UNCAUGHT: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}");
if (!headers_sent()) {
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->singleton(AuditLogger::class, function () use ($container) {
$cfg = $container->resolve(Config::class)->get('database');
return new AuditLogger($cfg);
});
// ─── CONTAINER REGISTRATION ───
$container = Container::getInstance();
// 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());
// State Machine
$container->singleton(StateMachine::class, function () use ($container) {
return new StateMachine($container->resolve(AuditLogger::class));
});
// Notification Manager
$container->singleton(NotificationManager::class, function () use ($container) {
return new NotificationManager($container->resolve(Connection::class));
});
// Calculation Engine
$container->singleton(CalculationEngine::class, fn() => new CalculationEngine());
// Validator
$container->singleton(Validator::class, function () use ($container) {
return new Validator($container->resolve(Connection::class));
});
// File Manager
$container->singleton(FileManager::class, function () use ($container) {
return new FileManager(
ROOT_PATH . '/storage/uploads',
$container->resolve(Connection::class)
);
});
// Search Engine
$container->singleton(SearchEngine::class, function () use ($container) {
return new SearchEngine($container->resolve(Connection::class));
});
// Export Manager
$container->singleton(ExportManager::class, function () {
return new ExportManager(ROOT_PATH . '/storage/exports');
});
// Job Runner
$container->singleton(JobRunner::class, function () use ($container) {
return new JobRunner($container->resolve(Connection::class));
});
// Router — 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->singleton(Validator::class, fn() => new Validator());
$container->singleton(TemplateEngine::class, fn() => new TemplateEngine());
$container->singleton(SearchEngine::class, fn() => new SearchEngine());
$container->singleton(FileManager::class, fn() => new FileManager());
$container->singleton(ExportManager::class, fn() => new ExportManager());
$container->singleton(QueryCache::class, fn() => new QueryCache());
$container->singleton(JobRunner::class, fn() => new JobRunner());
$container->singleton(Router::class, fn() => new Router());
// ─── CALCULATION ENGINE ───
$calcEngine = new CalculationEngine();
$calculators = require ROOT_PATH . '/config/calculators.php';
foreach ($calculators as $name => $class) {
$calcEngine->register($name, $class);
}
$container->instance(CalculationEngine::class, $calcEngine);
// ─── Register Calculators (silently skip if classes don't exist) ───
$calcEngine = $container->resolve(CalculationEngine::class);
$calculatorsFile = ROOT_PATH . '/config/calculators.php';
if (file_exists($calculatorsFile)) {
$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 ───
$routeFiles = glob(ROOT_PATH . '/modules/*/routes.php');
foreach ($routeFiles as $routeFile) {
require_once $routeFile;
}
// ─── Load All Module Routes ───
$router = $container->resolve(Router::class);
$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
return $container;
\ No newline at end of file
<?php
declare(strict_types=1);
define('ROOT_PATH', dirname(__DIR__));
spl_autoload_register(function (string $class): void {
// Namespace → directory mappings
$prefixes = [
'Engine\\' => ROOT_PATH . '/engine/',
'Middleware\\' => ROOT_PATH . '/middleware/',
'Modules\\' => ROOT_PATH . '/modules/',
'Middleware\\' => ROOT_PATH . '/middleware/',
];
foreach ($prefixes as $prefix => $baseDir) {
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
if (strncmp($class, $prefix, $len) !== 0) {
continue;
}
$relativeClass = substr($class, $len);
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require $file;
require_once $file;
return;
}
}
......
<?php
declare(strict_types=1);
define('ROOT_PATH', dirname(__DIR__));
require ROOT_PATH . '/bootstrap/autoload.php';
require_once __DIR__ . '/../bootstrap/app.php';
$dbConfig = require ROOT_PATH . '/config/database.php';
use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Auth\PasswordHasher;
try {
$dsn = "mysql:host={$dbConfig['host']};port={$dbConfig['port']};dbname={$dbConfig['database']};charset={$dbConfig['charset']}";
$options = $dbConfig['options'] ?? [];
$options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false;
$pdo = new PDO($dsn, $dbConfig['username'], $dbConfig['password'], $options);
} catch (PDOException $e) {
echo "DB Connection Failed: {$e->getMessage()}\n";
exit(1);
}
$db = Container::getInstance()->resolve(Connection::class);
$hasher = Container::getInstance()->resolve(PasswordHasher::class);
$username = getenv('SA_USERNAME') ?: 'admin';
$password = getenv('SA_PASSWORD') ?: 'Alarcade123#';
$nameEn = getenv('SA_NAME_EN') ?: 'Super Admin';
$nameAr = getenv('SA_NAME_AR') ?: 'مدير النظام';
// Check if super admin already exists
$stmt = $pdo->prepare("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1");
$stmt->execute();
if ($stmt->fetch()) {
echo "Super Admin already exists. Skipping.\n";
$exists = $db->fetchOne("SELECT id FROM users WHERE username = ?", [$username]);
if ($exists) {
echo "Super admin '{$username}' already exists (ID: {$exists['id']})\n";
exit(0);
}
$username = 'admin';
$password = 'Alarcade123#';
$nameEn = 'Mahmoud Aglan';
$nameAr = 'محمود عجلان';
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
$stmt = $pdo->prepare("
INSERT INTO users (
username, password_hash, role, full_name_en, full_name_ar,
national_id, date_of_birth, phone_primary, address,
emergency_contact_name, emergency_contact_phone, emergency_contact_relationship,
bank_name, bank_account_number, bank_account_holder,
status, is_active, activation_date, force_password_change
) VALUES (
?, ?, 'super_admin', ?, ?,
'00000000000000', '1990-01-01', '01000000000', 'AL-Arcade HQ',
'System', '01000000001', 'other',
'System', '0000000000', ?,
'active', 1, CURDATE(), 1
)
");
try {
$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
$id = $db->insert('users', [
'username' => $username,
'password_hash' => $hasher->hash($password),
'role' => 'super_admin',
'full_name_en' => $nameEn,
'full_name_ar' => $nameAr,
'national_id' => '00000000000000',
'date_of_birth' => '1990-01-01',
'phone_primary' => '+201000000000',
'address' => 'System',
'emergency_contact_name' => 'N/A',
'emergency_contact_phone' => '+201000000001',
'emergency_contact_relationship' => 'other',
'bank_name' => 'N/A',
'bank_account_number' => 'N/A',
'bank_account_holder' => $nameEn,
'status' => 'active',
'activation_date' => date('Y-m-d'),
'is_active' => 1,
'force_password_change' => 0,
]);
echo "✅ Super admin created: {$username} (ID: {$id})\n";
\ No newline at end of file
<?php
declare(strict_types=1);
/**
* 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';
require_once __DIR__ . '/../bootstrap/app.php';
use Engine\Core\Container;
use Engine\Scheduler\JobRunner;
$container = Container::getInstance();
$runner = $container->resolve(JobRunner::class);
echo "[" . date('Y-m-d H:i:s') . "] Cron runner started.\n";
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
$runner = Container::getInstance()->resolve(JobRunner::class);
$count = $runner->runDue();
echo date('Y-m-d H:i:s') . " — Ran {$count} jobs.\n";
\ No newline at end of file
......@@ -3,76 +3,40 @@ declare(strict_types=1);
namespace Engine\Audit;
use PDO;
use Engine\Core\Container;
use Engine\Database\Connection;
final class AuditLogger
{
private ?PDO $pdo = null;
private array $config;
private array $sensitiveFields = ['password', 'password_hash', 'temp_password_hash', 'key_hash', 'secret'];
private Connection $db;
public function __construct(array $config)
public function __construct()
{
$this->config = $config;
}
private function pdo(): PDO
{
if ($this->pdo === null) {
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=%s',
$this->config['host'], $this->config['port'], $this->config['database'], $this->config['charset']);
$this->pdo = new PDO($dsn, $this->config['username'], $this->config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => false,
]);
}
return $this->pdo;
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function log(
?array $user,
string $action,
string $entityType,
?int $entityId,
string $module,
?string $endpoint = null,
?array $before = null,
?array $after = null,
?string $ip = null,
?string $userAgent = null
?array $user, string $action, string $entityType, ?int $entityId,
string $module, string $endpoint, ?array $before = null, ?array $after = null,
?string $ip = null, ?string $userAgent = null
): void {
try {
$stmt = $this->pdo()->prepare(
"INSERT INTO audit_trail (user_id, username, user_role, action, entity_type, entity_id, module, endpoint, before_json, after_json, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
$stmt->execute([
$user['id'] ?? null,
$user['username'] ?? null,
$user['role'] ?? null,
$action,
$entityType,
$entityId,
$module,
$endpoint,
$before ? json_encode($this->sanitize($before)) : null,
$after ? json_encode($this->sanitize($after)) : null,
$ip,
$userAgent,
$this->db->insert('audit_trail', [
'user_id' => $user['id'] ?? null,
'username' => $user['username'] ?? null,
'user_role' => $user['role'] ?? null,
'action' => $action,
'entity_type' => $entityType,
'entity_id' => $entityId,
'module' => $module,
'endpoint' => $endpoint,
'before_json' => $before ? json_encode($before) : null,
'after_json' => $after ? json_encode($after) : null,
'ip_address' => $ip,
'user_agent' => $userAgent,
]);
} catch (\Throwable $e) {
error_log("[AuditLogger CRITICAL] Failed to log audit: {$e->getMessage()}");
}
}
private function sanitize(array $data): array
{
foreach ($this->sensitiveFields as $field) {
if (array_key_exists($field, $data)) {
$data[$field] = '***REDACTED***';
}
error_log("AuditLogger failed: " . $e->getMessage());
}
return $data;
}
}
\ No newline at end of file
......@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Engine\Auth;
use Engine\Core\Container;
use Engine\Database\Connection;
final class Authenticator
......@@ -10,29 +11,30 @@ final class Authenticator
private Connection $db;
private PasswordHasher $hasher;
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;
$this->hasher = $hasher;
$this->sessions = $sessions;
$this->rateLimiter = $rateLimiter;
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->hasher = $c->resolve(PasswordHasher::class);
$this->sessions = $c->resolve(SessionManager::class);
$this->limiter = $c->resolve(RateLimiter::class);
}
public function attempt(string $username, string $password, string $ip, string $userAgent): array
{
// Check rate limit
if ($this->rateLimiter->isLocked($username, $ip)) {
$this->logAttempt($username, $ip, $userAgent, false, 'locked');
return ['success' => false, 'error' => 'Account temporarily locked. Try again later.'];
// Rate limiting
if ($this->limiter->isLocked($username, $ip)) {
$this->logAttempt($username, $ip, $userAgent, false, 'rate_limited');
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) {
$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.'];
}
......@@ -41,67 +43,47 @@ final class Authenticator
return ['success' => false, 'error' => 'Account is deactivated.'];
}
if ($user['status'] === 'terminated') {
$this->logAttempt($username, $ip, $userAgent, false, 'terminated');
return ['success' => false, 'error' => 'Account has been terminated.'];
}
// Check temp password
// Check temp password first
$authenticated = false;
$usedTempPassword = false;
if ($user['temp_password_hash'] && $user['temp_password_expires_at'] > date('Y-m-d H:i:s')) {
if ($this->hasher->verify($password, $user['temp_password_hash'])) {
$authenticated = true;
$usedTempPassword = true;
if ($user['temp_password_hash'] && $user['temp_password_expires_at']) {
if (strtotime($user['temp_password_expires_at']) > time()) {
if ($this->hasher->verify($password, $user['temp_password_hash'])) {
$authenticated = true;
}
}
}
if (!$authenticated && $this->hasher->verify($password, $user['password_hash'])) {
$authenticated = true;
}
if (!$authenticated) {
if (!$authenticated && !$this->hasher->verify($password, $user['password_hash'])) {
$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.'];
}
// Success
$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
$this->db->update('users', ['last_login_at' => date('Y-m-d H:i:s')], 'id = ?', [$user['id']]);
// If used temp password, clear it and force change
if ($usedTempPassword) {
$this->db->update('users', [
'temp_password_hash' => null,
'temp_password_expires_at' => null,
'force_password_change' => 1,
], 'id = ?', [$user['id']]);
}
return [
'success' => true,
'user_id' => $user['id'],
'session_token' => $sessionToken,
'force_password_change' => (bool)$user['force_password_change'] || $usedTempPassword,
'role' => $user['role'],
'status' => $user['status'],
'success' => true,
'user_id' => $user['id'],
'role' => $user['role'],
'force_password_change' => (bool)$user['force_password_change'],
];
}
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', [
'username' => $username,
'ip_address' => $ip,
'user_agent' => $userAgent,
'success' => $success ? 1 : 0,
'username' => $username,
'ip_address' => $ip,
'user_agent' => $ua,
'success' => $success ? 1 : 0,
'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;
final class PasswordHasher
{
private int $cost;
public function __construct(int $cost = 12)
{
$this->cost = $cost;
}
private int $cost = 12;
public function hash(string $password): string
{
......
......@@ -3,104 +3,26 @@ declare(strict_types=1);
namespace Engine\Auth;
use Engine\Database\Connection;
final class PermissionEngine
{
private array $permissions;
private Connection $db;
public function __construct(array $permissions, Connection $db)
{
$this->permissions = $permissions;
$this->db = $db;
}
public function can(array $user, string $action, array $context = []): bool
{
$role = $user['role'];
// Super admin can do everything
if ($role === 'super_admin') {
return true;
}
if (!isset($this->permissions[$action])) {
return false;
}
$allowedRoles = $this->permissions[$action];
if (!in_array($role, $allowedRoles, true)) {
return false;
}
// Scope-based checks
return $this->checkScope($user, $action, $context);
}
private function checkScope(array $user, string $action, array $context): bool
{
$role = $user['role'];
// Project leader board scope
if ($role === 'project_leader' && isset($context['board_id'])) {
if (str_contains($action, 'own_boards') || str_contains($action, '.board')) {
return $this->isUserOnBoard($user['id'], (int)$context['board_id'], 'project_leader');
}
}
// Contractor own-card scope
if ($role === 'contractor' && isset($context['card_id'])) {
if (str_contains($action, 'own_cards') || str_contains($action, '.own')) {
return $this->isAssignedToCard($user['id'], (int)$context['card_id']);
}
}
// PL team scope for reports/evaluations
if ($role === 'project_leader' && isset($context['target_user_id'])) {
if (str_contains($action, 'own_team')) {
return $this->isOnSameBoard($user['id'], (int)$context['target_user_id']);
}
}
return true;
}
private function isUserOnBoard(int $userId, int $boardId, ?string $roleOnBoard = null): bool
{
$sql = "SELECT 1 FROM board_members WHERE user_id = ? AND board_id = ?";
$params = [$userId, $boardId];
if ($roleOnBoard) {
$sql .= " AND role_on_board = ?";
$params[] = $roleOnBoard;
}
return (bool)$this->db->fetchOne($sql, $params);
}
private function isAssignedToCard(int $userId, int $cardId): bool
public function __construct()
{
return (bool)$this->db->fetchOne(
"SELECT 1 FROM card_assignments WHERE user_id = ? AND card_id = ?",
[$userId, $cardId]
);
$this->permissions = require ROOT_PATH . '/config/permissions.php';
}
private function isOnSameBoard(int $userId, int $targetUserId): bool
public function can(array $user, string $permission, array $context = []): bool
{
return (bool)$this->db->fetchOne(
"SELECT 1 FROM board_members bm1
JOIN board_members bm2 ON bm1.board_id = bm2.board_id
WHERE bm1.user_id = ? AND bm2.user_id = ?",
[$userId, $targetUserId]
);
$role = $user['role'] ?? '';
$allowedRoles = $this->permissions[$permission] ?? [];
return in_array($role, $allowedRoles, true);
}
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)) {
throw new \RuntimeException("Permission denied: {$action}", 403);
if (!$this->can($user, $permission, $context)) {
throw new \Engine\Auth\ForbiddenException("Permission denied: {$permission}");
}
}
}
\ No newline at end of file
......@@ -3,41 +3,28 @@ declare(strict_types=1);
namespace Engine\Auth;
use Engine\Core\Container;
use Engine\Database\Connection;
final class RateLimiter
{
private Connection $db;
private int $maxAttempts = 5;
private int $lockoutSeconds = 1800; // 30 minutes
private int $maxDailyAttempts = 15;
private int $lockoutMinutes = 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
{
// Check recent failures for username
$recentFailures = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM login_attempts
WHERE username = ? AND success = 0 AND created_at > DATE_SUB(NOW(), INTERVAL ? SECOND)",
[$username, $this->lockoutSeconds]
$since = date('Y-m-d H:i:s', strtotime("-{$this->lockoutMinutes} minutes"));
$count = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM login_attempts WHERE (username = ? OR ip_address = ?) AND success = 0 AND created_at > ?",
[$username, $ip, $since]
);
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;
return $count >= $this->maxAttempts;
}
public function recordFailure(string $username, string $ip): void
......@@ -47,6 +34,6 @@ final class RateLimiter
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);
namespace Engine\Auth;
use Engine\Core\Container;
use Engine\Database\Connection;
final class SessionManager
{
private Connection $db;
private string $cookieName = 'al_arcade_session';
private int $lifetime = 28800; // 8 hours
public function __construct(Connection $db)
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
{
$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', [
'id' => $token,
'user_id' => $userId,
'ip_address' => $ip,
'user_agent' => $userAgent,
'id' => $sessionId,
'user_id' => $userId,
'ip_address' => $ip,
'user_agent' => $userAgent,
'last_activity_at' => date('Y-m-d H:i:s'),
]);
setcookie($this->cookieName, $token, [
'expires' => time() + $this->lifetime,
'path' => '/',
'httponly' => true,
'secure' => isset($_SERVER['HTTPS']),
'samesite' => 'Lax',
]);
$_SESSION['user_id'] = $userId;
$_SESSION['session_id'] = $sessionId;
return $token;
return $sessionId;
}
public function validate(): ?array
{
$token = $_COOKIE[$this->cookieName] ?? null;
if (!$token) return null;
$this->start();
if (empty($_SESSION['user_id'])) return null;
$userId = (int)$_SESSION['user_id'];
$sessionId = session_id();
$session = $this->db->fetchOne(
"SELECT s.*, u.id as uid, u.username, u.role, u.full_name_en, u.status, u.is_active,
u.contractor_type, u.assigned_pl_id, u.actual_salary, u.base_salary,
u.force_password_change, u.theme_preference, u.profile_photo_id
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.last_activity_at > DATE_SUB(NOW(), INTERVAL ? SECOND)",
[$token, $this->lifetime]
"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",
[$sessionId, $userId]
);
if (!$session) {
$this->destroyToken($token);
$this->destroy();
return null;
}
if (!$session['is_active']) {
$this->destroyToken($token);
// Update last activity (throttle to every 60 seconds)
$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;
}
// Touch activity
$this->db->update('sessions', ['last_activity_at' => date('Y-m-d H:i:s')], 'id = ?', [$token]);
return [
'session_id' => $session['id'],
'id' => $session['uid'],
'username' => $session['username'],
'role' => $session['role'],
'full_name_en' => $session['full_name_en'],
'status' => $session['status'],
'is_active' => $session['is_active'],
'contractor_type' => $session['contractor_type'],
'assigned_pl_id' => $session['assigned_pl_id'],
'actual_salary' => $session['actual_salary'],
'base_salary' => $session['base_salary'],
'force_password_change'=> $session['force_password_change'],
'theme_preference' => $session['theme_preference'],
'profile_photo_id' => $session['profile_photo_id'],
];
return $session;
}
public function destroy(): void
{
$token = $_COOKIE[$this->cookieName] ?? null;
if ($token) {
$this->destroyToken($token);
$this->start();
$sessionId = session_id();
$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
{
$this->db->delete('sessions', 'id = ?', [$token]);
setcookie($this->cookieName, '', ['expires' => time() - 3600, 'path' => '/']);
}
public function destroyAllForUser(int $userId, ?string $except = null): void
public function destroyAllForUser(int $userId, ?string $exceptSessionId = null): void
{
if ($except) {
$this->db->query('DELETE FROM sessions WHERE user_id = ? AND id != ?', [$userId, $except]);
if ($exceptSessionId) {
$this->db->query("DELETE FROM sessions WHERE user_id = ? AND id != ?", [$userId, $exceptSessionId]);
} else {
$this->db->delete('sessions', 'user_id = ?', [$userId]);
}
}
public function listForUser(int $userId): array
public function generateCsrfToken(): string
{
return $this->db->fetchAll(
'SELECT id, ip_address, user_agent, last_activity_at, created_at FROM sessions WHERE user_id = ? ORDER BY last_activity_at DESC',
[$userId]
);
$this->start();
if (empty($_SESSION['csrf_token'])) {
$_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;
final class QueryCache
{
private static array $cache = [];
private static int $hits = 0;
private static int $misses = 0;
private static int $maxEntries = 500;
private static int $defaultTtl = 60;
private array $cache = [];
public static function get(string $key): mixed
{
if (isset(self::$cache[$key])) {
$entry = self::$cache[$key];
if ($entry['expires_at'] > time()) {
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,
];
}
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; }
public function has(string $key): bool { return isset($this->cache[$key]); }
public function forget(string $key): void { unset($this->cache[$key]); }
public function flush(): void { $this->cache = []; }
}
\ No newline at end of file
......@@ -7,21 +7,23 @@ final class CalculationEngine
{
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])) {
throw new \RuntimeException("Calculator not registered: {$name}");
}
return $this->calculators[$name]->calculate($context);
return isset($this->calculators[$name]);
}
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;
final class Config
{
private array $data = [];
private string $configPath;
private array $items = [];
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);
$file = array_shift($parts);
$configPath = ROOT_PATH . '/config';
if (!is_dir($configPath)) return;
if (!isset($this->data[$file])) {
$path = $this->configPath . '/' . $file . '.php';
if (file_exists($path)) {
$this->data[$file] = require $path;
} else {
return $default;
}
foreach (glob($configPath . '/*.php') as $file) {
$key = basename($file, '.php');
$this->items[$key] = require $file;
}
}
public function get(string $key, mixed $default = null): mixed
{
$parts = explode('.', $key);
$value = $this->items;
$value = $this->data[$file];
foreach ($parts as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) {
return $default;
......@@ -38,16 +38,27 @@ final class Config
return $value;
}
public function all(string $file): array
public function set(string $key, mixed $value): void
{
if (!isset($this->data[$file])) {
$path = $this->configPath . '/' . $file . '.php';
if (file_exists($path)) {
$this->data[$file] = require $path;
$parts = explode('.', $key);
$target = &$this->items;
foreach ($parts as $i => $part) {
if ($i === count($parts) - 1) {
$target[$part] = $value;
} 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;
final class Container
{
private static ?self $instance = null;
private static ?Container $instance = null;
private array $bindings = [];
private array $singletons = [];
private array $resolved = [];
......@@ -18,41 +18,48 @@ final class Container
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
{
if (isset($this->resolved[$abstract])) {
if (isset($this->resolved[$abstract]) && isset($this->singletons[$abstract])) {
return $this->resolved[$abstract];
}
if (!isset($this->bindings[$abstract])) {
if (class_exists($abstract)) {
return new $abstract();
}
throw new \RuntimeException("No binding found for: {$abstract}");
}
$concrete = $this->bindings[$abstract] ?? $abstract;
$binding = $this->bindings[$abstract];
$instance = ($binding['factory'])();
if (is_callable($concrete)) {
$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']) {
$this->resolved[$abstract] = $instance;
if (isset($this->singletons[$abstract])) {
$this->resolved[$abstract] = $object;
}
return $instance;
return $object;
}
public function has(string $abstract): bool
{
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;
final class MiddlewarePipeline
{
private Container $container;
private Request $request;
private array $middleware = [];
public function __construct(Container $container)
{
$this->container = $container;
}
public function send(Request $request): self
{
$this->request = $request;
......@@ -31,14 +25,12 @@ final class MiddlewarePipeline
$pipeline = array_reduce(
array_reverse($this->middleware),
function (callable $next, string $middlewareClass) {
return function (Request $request) use ($middlewareClass, $next): Response {
$middleware = $this->container->resolve($middlewareClass);
return $middleware->handle($request, $next);
return function (Request $request) use ($next, $middlewareClass) {
$instance = new $middlewareClass();
return $instance->handle($request, $next);
};
},
function (Request $request) use ($destination): Response {
return $destination($request);
}
$destination
);
return $pipeline($this->request);
......
......@@ -5,107 +5,119 @@ namespace Engine\Core;
final class Request
{
private string $method;
private string $uri;
private array $query;
private array $body;
private array $files;
private array $post;
private array $server;
private array $cookies;
private array $headers;
private array $cookies;
private ?array $jsonBody = null;
private ?array $user = null;
private array $attributes = [];
private function __construct()
public function __construct()
{
$this->method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
$this->uri = $this->parseUri();
$this->query = $_GET;
$this->body = $this->parseBody();
$this->files = $_FILES;
$this->server = $_SERVER;
$this->query = $_GET;
$this->post = $_POST;
$this->server = $_SERVER;
$this->cookies = $_COOKIE;
$this->headers = $this->parseHeaders();
}
public static function capture(): self
{
return new self();
}
private function parseUri(): string
{
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$uri = strtok($uri, '?');
$uri = '/' . trim($uri, '/');
return $uri === '/' ? '/' : rtrim($uri, '/');
}
private function parseBody(): array
{
if ($this->method === 'GET') {
return [];
}
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (str_contains($contentType, 'application/json')) {
$raw = file_get_contents('php://input');
return json_decode($raw, true) ?? [];
}
return $_POST;
}
private function parseHeaders(): array
{
$headers = [];
foreach ($_SERVER as $key => $value) {
foreach ($this->server as $key => $value) {
if (str_starts_with($key, 'HTTP_')) {
$name = str_replace('_', '-', strtolower(substr($key, 5)));
$headers[$name] = $value;
}
}
if (isset($this->server['CONTENT_TYPE'])) {
$headers['content-type'] = $this->server['CONTENT_TYPE'];
}
return $headers;
}
public function method(): string { return $this->method; }
public function uri(): string { return $this->uri; }
public function isJson(): bool { return str_contains($this->header('content-type', ''), 'json'); }
public function isAjax(): bool { return $this->header('x-requested-with') === 'XMLHttpRequest'; }
public function wantsJson(): bool { return str_contains($this->header('accept', ''), 'json') || $this->isJson(); }
public function method(): string
{
$method = strtoupper($this->server['REQUEST_METHOD'] ?? 'GET');
if ($method === 'POST') {
$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;
}
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
{
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
{
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
......@@ -118,12 +130,29 @@ final class Request
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
......@@ -131,9 +160,14 @@ final class Request
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
......
......@@ -5,94 +5,53 @@ namespace Engine\Core;
final class Response
{
private int $statusCode;
private array $headers;
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->statusCode = $statusCode;
$this->status = $status;
$this->headers = $headers;
}
public static function json(mixed $data, int $status = 200, array $headers = []): self
{
$headers['Content-Type'] = 'application/json; charset=utf-8';
return new self(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), $status, $headers);
}
public static function html(string $html, int $status = 200): self
{
return new self($html, $status, ['Content-Type' => 'text/html; charset=utf-8']);
}
public static function redirect(string $url, int $status = 302): self
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
{
$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
public static function redirect(string $url, int $status = 302): self
{
$this->headers[$key] = $value;
return $this;
return new self('', $status, ['Location' => $url]);
}
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, [
'expires' => $expire,
'path' => $path,
'httponly' => $httpOnly,
'secure' => $secure,
'samesite' => $sameSite,
]);
return $this;
return new self($body, $status, $headers);
}
public function send(): void
{
http_response_code($this->statusCode);
foreach ($this->headers as $key => $value) {
header("{$key}: {$value}");
}
if (str_starts_with($this->body, '__FILE__:')) {
$file = substr($this->body, 9);
readfile($file);
} else {
echo $this->body;
if (!headers_sent()) {
http_response_code($this->status);
foreach ($this->headers as $name => $value) {
header("{$name}: {$value}");
}
}
echo $this->body;
}
public function getStatusCode(): int { return $this->statusCode; }
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
......@@ -5,215 +5,173 @@ namespace Engine\Core;
final class Router
{
private static ?self $instance = null;
private array $routes = [];
private array $groupStack = [];
/**
* Middleware alias map — short names to full class names.
*/
private static array $middlewareAliases = [
'auth' => \Middleware\AuthenticationMiddleware::class,
'audit' => \Middleware\AuditMiddleware::class,
'json_body' => \Middleware\JsonBodyParserMiddleware::class,
'api_key_auth' => \Middleware\ApiKeyAuthMiddleware::class,
'cors' => \Middleware\CORSMiddleware::class,
'csrf' => \Middleware\CSRFMiddleware::class,
'blocking' => \Middleware\BlockingNotificationMiddleware::class,
'security' => \Middleware\SecurityHeadersMiddleware::class,
];
public function __construct()
{
if (self::$instance === null) {
self::$instance = $this;
}
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
private static array $routes = [];
private static array $groupStack = [];
private static ?Router $instance = null;
/**
* Resolve middleware alias to full class name.
*/
public static function resolveMiddleware(string $middleware): string
public static function get(string $path, array|string $handler, ?string $method = null): void
{
return self::$middlewareAliases[$middleware] ?? $middleware;
self::addRoute('GET', $path, $handler, $method);
}
// ─── Static Route Registration (supports both calling patterns) ───
/**
* Supports:
* Router::get('/path', [Controller::class, 'method'])
* Router::get('/path', Controller::class, 'method')
*/
public static function get(string $uri, $handler, ?string $action = null): self
{
return self::getInstance()->addRoute('GET', $uri, $handler, $action);
}
public static function post(string $uri, $handler, ?string $action = null): self
public static function post(string $path, array|string $handler, ?string $method = null): void
{
return self::getInstance()->addRoute('POST', $uri, $handler, $action);
self::addRoute('POST', $path, $handler, $method);
}
public static function put(string $uri, $handler, ?string $action = null): self
public static function put(string $path, array|string $handler, ?string $method = null): void
{
return self::getInstance()->addRoute('PUT', $uri, $handler, $action);
self::addRoute('PUT', $path, $handler, $method);
}
public static function delete(string $uri, $handler, ?string $action = null): self
public static function delete(string $path, array|string $handler, ?string $method = null): void
{
return self::getInstance()->addRoute('DELETE', $uri, $handler, $action);
self::addRoute('DELETE', $path, $handler, $method);
}
/**
* Route group. Supports both calling patterns:
*
* Router::group('/prefix', ['middleware' => ['auth']], function() { ... })
* $router->group(['prefix' => '/x', 'middleware' => [Class::class]], function($r) { ... })
*/
public static function group($prefixOrAttributes, $attributesOrCallback = null, $callback = null): void
public static function group(string $prefix, array|callable $optionsOrCallback, ?callable $callback = null): void
{
$instance = self::getInstance();
if (is_string($prefixOrAttributes)) {
// Pattern: group('/prefix', ['middleware' => [...]], callable)
// OR: group('/prefix', callable)
if (is_callable($attributesOrCallback) && $callback === null) {
$attributes = ['prefix' => $prefixOrAttributes];
$cb = $attributesOrCallback;
} else {
$attributes = is_array($attributesOrCallback) ? $attributesOrCallback : [];
$attributes['prefix'] = $prefixOrAttributes;
$cb = $callback;
}
} elseif (is_array($prefixOrAttributes)) {
// Pattern: group(['prefix' => '/x', 'middleware' => [...]], callable)
$attributes = $prefixOrAttributes;
$cb = $attributesOrCallback;
if (is_callable($optionsOrCallback)) {
$callback = $optionsOrCallback;
$options = [];
} else {
return;
$options = $optionsOrCallback;
}
$instance->groupStack[] = $attributes;
$parentPrefix = end(self::$groupStack)['prefix'] ?? '';
$parentMiddleware = end(self::$groupStack)['middleware'] ?? [];
if (is_callable($cb)) {
$cb($instance);
}
self::$groupStack[] = [
'prefix' => $parentPrefix . $prefix,
'middleware' => array_merge($parentMiddleware, $options['middleware'] ?? []),
];
array_pop($instance->groupStack);
$callback();
array_pop(self::$groupStack);
}
/**
* Chainable middleware on last registered route.
*/
public function middleware($middleware): self
private static function addRoute(string $httpMethod, string $path, array|string $handler, ?string $method = null): void
{
$last = array_key_last($this->routes);
if ($last !== null) {
$mw = is_array($middleware) ? $middleware : [$middleware];
$this->routes[$last]['middleware'] = array_merge(
$this->routes[$last]['middleware'] ?? [],
$mw
);
}
return $this;
}
$group = end(self::$groupStack) ?: ['prefix' => '', 'middleware' => []];
$fullPath = $group['prefix'] . $path;
$fullPath = rtrim($fullPath, '/') ?: '/';
// ─── Internal Route Registration ───
private function addRoute(string $httpMethod, string $uri, $handler, ?string $action = null): self
{
// Handle [Controller::class, 'method'] array syntax
if (is_array($handler)) {
if (is_array($handler) && $method === null) {
$controller = $handler[0];
$action = $handler[1] ?? $action;
$method = $handler[1];
} elseif (is_string($handler)) {
$controller = $handler;
$method = $method ?? '__invoke';
} else {
$controller = $handler;
$method = $method ?? '__invoke';
}
if (!$action) {
throw new \RuntimeException("Route {$httpMethod} {$uri}: no action/method specified.");
}
self::$routes[] = [
'method' => $httpMethod,
'path' => $fullPath,
'pattern' => self::pathToPattern($fullPath),
'controller' => $controller,
'action' => $method,
'middleware' => $group['middleware'],
];
}
// Build prefix and middleware from group stack
$prefix = '';
$middleware = [];
private static function pathToPattern(string $path): string
{
$pattern = preg_replace('/\{([a-zA-Z_]+)\}/', '(?P<$1>[^/]+)', $path);
return '#^' . $pattern . '$#';
}
foreach ($this->groupStack as $group) {
if (isset($group['prefix'])) {
$prefix .= '/' . trim($group['prefix'], '/');
}
if (isset($group['middleware'])) {
$mw = is_array($group['middleware']) ? $group['middleware'] : [$group['middleware']];
$middleware = array_merge($middleware, $mw);
public function dispatch(Request $request): Response
{
$method = $request->method();
$path = $request->path();
foreach (self::$routes as $route) {
if ($route['method'] !== $method) continue;
if (preg_match($route['pattern'], $path, $matches)) {
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
// Build middleware pipeline
$middlewareClasses = $this->resolveMiddleware($route['middleware']);
$pipeline = new MiddlewarePipeline();
$response = $pipeline->send($request)
->through($middlewareClasses)
->then(function (Request $request) use ($route, $params) {
return $this->callController($route['controller'], $route['action'], $request, $params);
});
return $response;
}
}
// Resolve middleware aliases to full class names
$middleware = array_map([self::class, 'resolveMiddleware'], $middleware);
$fullUri = $prefix . '/' . ltrim($uri, '/');
$fullUri = '/' . trim($fullUri, '/');
if ($fullUri !== '/') {
$fullUri = rtrim($fullUri, '/');
// 404
if ($request->wantsJson()) {
return Response::json(['error' => 'Not found'], 404);
}
$templatePath = ROOT_PATH . '/templates/errors/404.php';
if (file_exists($templatePath)) {
ob_start();
require $templatePath;
return Response::html(ob_get_clean(), 404);
}
return Response::html('<h1>404 Not Found</h1>', 404);
}
$this->routes[] = [
'httpMethod' => $httpMethod,
'uri' => $fullUri,
'controller' => $controller,
'method' => $action,
'middleware' => $middleware,
'pattern' => $this->compilePattern($fullUri),
private function resolveMiddleware(array $names): array
{
$map = [
'auth' => \Middleware\AuthenticationMiddleware::class,
'csrf' => \Middleware\CSRFMiddleware::class,
'cors' => \Middleware\CORSMiddleware::class,
'json_body' => \Middleware\JsonBodyParserMiddleware::class,
'audit' => \Middleware\AuditMiddleware::class,
'security_headers' => \Middleware\SecurityHeadersMiddleware::class,
'blocking_notif' => \Middleware\BlockingNotificationMiddleware::class,
'api_key_auth' => \Middleware\ApiKeyAuthMiddleware::class,
];
return $this;
$resolved = [];
foreach ($names as $name) {
$class = $map[$name] ?? $name;
if (class_exists($class)) {
$resolved[] = $class;
}
}
return $resolved;
}
private function compilePattern(string $uri): string
private function callController(string $controller, string $action, Request $request, array $params): Response
{
$pattern = preg_replace('/\{([a-zA-Z_]+)\}/', '(?P<$1>[^/]+)', $uri);
return '#^' . $pattern . '$#';
}
if (!class_exists($controller)) {
throw new \RuntimeException("Controller [{$controller}] not found.");
}
public function match(Request $request): ?array
{
$method = $request->method();
$uri = $request->uri();
$instance = new $controller();
// Support PUT/DELETE via _method field
if ($method === 'POST') {
$override = $request->input('_method');
if ($override && in_array(strtoupper($override), ['PUT', 'DELETE'])) {
$method = strtoupper($override);
}
if (!method_exists($instance, $action)) {
throw new \RuntimeException("Method [{$action}] not found on [{$controller}].");
}
foreach ($this->routes as $route) {
if ($route['httpMethod'] !== $method) {
continue;
}
$args = [$request];
foreach ($params as $value) {
$args[] = $value;
}
if (preg_match($route['pattern'], $uri, $matches)) {
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
$route['params'] = array_values($params);
return $route;
}
$result = call_user_func_array([$instance, $action], $args);
if ($result instanceof Response) {
return $result;
}
return null;
return Response::html((string)$result);
}
public function getRoutes(): array
public static function getRoutes(): array
{
return $this->routes;
return self::$routes;
}
}
\ No newline at end of file
......@@ -3,57 +3,39 @@ declare(strict_types=1);
namespace Engine\Database;
use PDO;
final class Connection
{
private ?PDO $pdo = null;
private array $config;
public function __construct(array $config)
{
$this->config = $config;
}
private \PDO $pdo;
private static ?Connection $instance = null;
public function pdo(): PDO
public function __construct()
{
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']
);
$config = require ROOT_PATH . '/config/database.php';
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=%s',
$config['host'], $config['port'], $config['database'], $config['charset']
);
$options = $this->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;
$this->pdo = new \PDO($dsn, $config['username'], $config['password'], $config['options'] ?? []);
}
public function getPdo(): \PDO { return $this->pdo; }
public function query(string $sql, array $params = []): \PDOStatement
{
$stmt = $this->pdo()->prepare($sql);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
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 $result ?: null;
return $this->query($sql, $params)->fetchAll();
}
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
......@@ -67,53 +49,38 @@ final class Connection
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO `{$table}` ({$columns}) VALUES ({$placeholders})";
$this->query($sql, array_values($data));
return (int) $this->pdo()->lastInsertId();
return (int)$this->pdo->lastInsertId();
}
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
$sets = implode(', ', array_map(fn($c) => "`{$c}` = ?", array_keys($data)));
$sql = "UPDATE `{$table}` SET {$sets} WHERE {$where}";
$set = implode(', ', array_map(fn($c) => "`{$c}` = ?", array_keys($data)));
$sql = "UPDATE `{$table}` SET {$set} WHERE {$where}";
$stmt = $this->query($sql, array_merge(array_values($data), $whereParams));
return $stmt->rowCount();
}
public function delete(string $table, string $where, array $params = []): int
{
$sql = "DELETE FROM `{$table}` WHERE {$where}";
return $this->query($sql, $params)->rowCount();
}
public function beginTransaction(): void
{
$this->pdo()->beginTransaction();
}
public function commit(): void
{
$this->pdo()->commit();
}
public function rollBack(): void
{
$this->pdo()->rollBack();
$stmt = $this->query("DELETE FROM `{$table}` WHERE {$where}", $params);
return $stmt->rowCount();
}
public function transaction(callable $callback): mixed
{
$this->beginTransaction();
$this->pdo->beginTransaction();
try {
$result = $callback($this);
$this->commit();
$this->pdo->commit();
return $result;
} catch (\Throwable $e) {
$this->rollBack();
$this->pdo->rollBack();
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;
final class QueryBuilder
{
private Connection $db;
private string $table;
private string $table = '';
private array $selects = ['*'];
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 $orderBys = [];
private array $groupBys = [];
private ?int $limitVal = null;
private ?int $offsetVal = null;
public function __construct(Connection $db, string $table)
{
$this->db = $db;
$this->table = $table;
}
public static function table(Connection $db, string $table): self
{
return new self($db, $table);
}
public function select(string ...$columns): self
{
$this->selects = $columns;
return $this;
}
public function where(string $column, string $operator, mixed $value): self
{
$this->wheres[] = "`{$column}` {$operator} ?";
$this->bindings[] = $value;
return $this;
}
public function whereNull(string $column): self
{
$this->wheres[] = "`{$column}` IS NULL";
return $this;
}
public function __construct(Connection $db) { $this->db = $db; }
public function whereNotNull(string $column): self
{
$this->wheres[] = "`{$column}` IS NOT NULL";
return $this;
}
public function whereIn(string $column, array $values): self
{
if (empty($values)) {
$this->wheres[] = '0 = 1';
return $this;
}
$placeholders = implode(', ', array_fill(0, count($values), '?'));
$this->wheres[] = "`{$column}` IN ({$placeholders})";
$this->bindings = array_merge($this->bindings, array_values($values));
return $this;
}
public function whereRaw(string $raw, array $bindings = []): self
{
$this->wheres[] = $raw;
$this->bindings = array_merge($this->bindings, $bindings);
return $this;
}
public function 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; }
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}";
return $this;
$c = clone $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}";
return $this;
$c = clone $this;
$c->orderBy[] = "`{$col}` " . (strtoupper($dir) === 'DESC' ? 'DESC' : 'ASC');
return $c;
}
public function groupBy(string ...$columns): self
{
$this->groupBys = array_merge($this->groupBys, $columns);
return $this;
}
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; }
public function limit(int $limit): self
{
$this->limitVal = $limit;
return $this;
}
public function offset(int $offset): self
{
$this->offsetVal = $offset;
return $this;
}
public function get(): array
{
return $this->db->fetchAll($this->toSql(), $this->bindings);
}
public function first(): ?array
{
$this->limitVal = 1;
return $this->db->fetchOne($this->toSql(), $this->bindings);
}
public function count(): int
{
$original = $this->selects;
$this->selects = ['COUNT(*) as cnt'];
$result = $this->first();
$this->selects = $original;
return (int)($result['cnt'] ?? 0);
}
public function sum(string $column): float
{
$original = $this->selects;
$this->selects = ["COALESCE(SUM(`{$column}`), 0) as total"];
$result = $this->first();
$this->selects = $original;
return (float)($result['total'] ?? 0);
}
public function exists(): bool
{
return $this->count() > 0;
}
public function paginate(int $page, int $perPage = 25): array
{
$total = $this->count();
$this->limitVal = $perPage;
$this->offsetVal = ($page - 1) * $perPage;
$data = $this->get();
return [
'data' => $data,
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => (int)ceil($total / $perPage),
];
}
public function get(): array { return $this->db->fetchAll($this->toSql(), $this->params); }
public function first(): ?array { return $this->limit(1)->db->fetchOne($this->toSql(), $this->params); }
public function count(): int { $c = clone $this; $c->selects = ['COUNT(*)']; return (int)$this->db->fetchColumn($c->toSql(), $c->params); }
public function toSql(): string
{
$sql = 'SELECT ' . implode(', ', $this->selects);
$sql .= " FROM `{$this->table}`";
foreach ($this->joins as $join) {
$sql .= " {$join}";
}
if (!empty($this->wheres)) {
$sql .= ' WHERE ' . implode(' AND ', $this->wheres);
}
if (!empty($this->groupBys)) {
$sql .= ' GROUP BY ' . implode(', ', array_map(fn($c) => "`{$c}`", $this->groupBys));
}
if (!empty($this->orderBys)) {
$sql .= ' ORDER BY ' . implode(', ', $this->orderBys);
}
if ($this->limitVal !== null) {
$sql .= " LIMIT {$this->limitVal}";
}
if ($this->offsetVal !== null) {
$sql .= " OFFSET {$this->offsetVal}";
}
$sql = 'SELECT ' . implode(', ', $this->selects) . " FROM `{$this->table}`";
if ($this->joins) $sql .= ' ' . implode(' ', $this->joins);
if ($this->wheres) $sql .= ' WHERE ' . implode(' AND ', $this->wheres);
if ($this->orderBy) $sql .= ' ORDER BY ' . implode(', ', $this->orderBy);
if ($this->limit !== null) $sql .= " LIMIT {$this->limit}";
if ($this->offset !== null) $sql .= " OFFSET {$this->offset}";
return $sql;
}
}
\ No newline at end of file
......@@ -6,44 +6,28 @@ namespace Engine\Events;
final class EventDispatcher
{
private array $listeners = [];
private int $depth = 0;
private int $maxDepth = 10;
public function listen(string $event, callable $listener): void
public function listen(string $event, string|callable $listener): void
{
$this->listeners[$event][] = $listener;
}
public function fire(string $event, array $payload = []): void
{
$this->depth++;
if ($this->depth > $this->maxDepth) {
$this->depth--;
error_log("[EventDispatcher] Max depth reached for event: {$event}");
return;
}
try {
$listeners = $this->listeners[$event] ?? [];
foreach ($listeners as $listener) {
$listener($payload);
}
// Also fire wildcard listeners
foreach ($this->listeners as $pattern => $patternListeners) {
if (str_contains($pattern, '*') && fnmatch($pattern, $event)) {
foreach ($patternListeners as $listener) {
$listener($payload);
$listeners = $this->listeners[$event] ?? [];
foreach ($listeners as $listener) {
try {
if (is_callable($listener)) {
$listener($event, $payload);
} elseif (is_string($listener) && class_exists($listener)) {
$instance = new $listener();
if ($instance instanceof ListenerInterface) {
$instance->handle($event, $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;
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;
final class ExportManager
{
private string $exportDir;
public function __construct(string $exportDir)
{
$this->exportDir = rtrim($exportDir, '/');
if (!is_dir($this->exportDir)) {
mkdir($this->exportDir, 0755, true);
}
}
public function csv(array $data, array $headers, string $filename): string
{
$path = $this->exportDir . '/' . $filename;
$fp = fopen($path, 'w');
fputcsv($fp, $headers);
foreach ($data as $row) {
fputcsv($fp, $row);
}
fclose($fp);
return $path;
}
public function streamCsv(array $data, array $headers, string $filename): void
public function toCsv(array $rows, array $headers = []): string
{
header('Content-Type: text/csv; charset=utf-8');
header("Content-Disposition: attachment; filename=\"{$filename}\"");
$fp = fopen('php://output', 'w');
fputcsv($fp, $headers);
foreach ($data as $row) {
fputcsv($fp, $row);
}
fclose($fp);
if (empty($rows)) return '';
ob_start();
$out = fopen('php://output', 'w');
fputcsv($out, $headers ?: array_keys($rows[0]));
foreach ($rows as $row) fputcsv($out, array_values($row));
fclose($out);
return ob_get_clean();
}
}
\ No newline at end of file
......@@ -3,99 +3,29 @@ declare(strict_types=1);
namespace Engine\FileStorage;
use Engine\Core\Container;
use Engine\Database\Connection;
final class FileManager
{
private string $uploadDir;
private Connection $db;
private array $allowedMimes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf', 'text/plain', 'text/csv',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip', 'application/x-zip-compressed',
];
private int $maxSize = 26214400; // 25MB
private string $uploadDir;
public function __construct(string $uploadDir, Connection $db)
public function __construct()
{
$this->uploadDir = rtrim($uploadDir, '/');
$this->db = $db;
$this->db = Container::getInstance()->resolve(Connection::class);
$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) {
return null;
}
$mime = mime_content_type($file['tmp_name']);
if (!in_array($mime, $this->allowedMimes)) {
throw new \RuntimeException("File type not allowed: {$mime}");
}
if ($file['size'] > $this->maxSize) {
throw new \RuntimeException("File exceeds maximum size of 25MB.");
}
$date = date('Y/m/d');
$dir = $this->uploadDir . '/' . $date;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$storedName = bin2hex(random_bytes(16)) . '.' . $ext;
$storagePath = $date . '/' . $storedName;
$fullPath = $dir . '/' . $storedName;
if (!move_uploaded_file($file['tmp_name'], $fullPath)) {
throw new \RuntimeException("Failed to move uploaded file.");
}
$id = $this->db->insert('file_uploads', [
'original_name' => $file['name'],
'stored_name' => $storedName,
'mime_type' => $mime,
'size_bytes' => $file['size'],
'storage_path' => $storagePath,
'uploaded_by_id' => $uploadedBy,
if ($file['error'] !== UPLOAD_ERR_OK) 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;
return $this->db->insert('file_uploads', [
'original_name' => $file['name'], 'stored_name' => $storedName, 'mime_type' => $file['type'],
'size_bytes' => $file['size'], 'storage_path' => 'storage/uploads/' . $storedName, 'uploaded_by_id' => $uploadedById,
]);
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);
namespace Engine\Notifications;
use Engine\Core\Container;
use Engine\Database\Connection;
final class NotificationManager
{
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', [
'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);
return $this->create($userId, 'blocking', $title, $content, $url, $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
{
return $this->create($userId, 'informational', $title, $content, $linkUrl);
}
public function getUnreadCount(int $userId): int
public function createInformational(int $userId, string $title, string $content, ?string $url = null, ?string $entityType = null, ?int $entityId = null): int
{
return (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM notifications WHERE user_id = ? AND is_read = 0",
[$userId]
);
return $this->create($userId, 'informational', $title, $content, $url, $entityType, $entityId);
}
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(
"SELECT * FROM notifications WHERE user_id = ? AND tier = 'blocking' AND is_acknowledged = 0 ORDER BY created_at ASC",
[$userId]
);
return $this->db->insert('notifications', [
'user_id' => $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(
"SELECT COUNT(*) FROM notifications WHERE user_id = ? AND tier = 'blocking' AND is_acknowledged = 0",
[$userId]
);
return $this->db->fetchAll("SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ?", [$userId, $limit]);
}
public function getRecent(int $userId, int $limit = 20): array
public function getUnreadCount(int $userId): int
{
return $this->db->fetchAll(
"SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
[$userId, $limit]
);
return (int)$this->db->fetchColumn("SELECT COUNT(*) FROM notifications WHERE user_id = ? AND is_read = 0", [$userId]);
}
public function markAsRead(int $notificationId, int $userId): void
public function getBlockingUnacknowledged(int $userId): array
{
$this->db->update('notifications', [
'is_read' => 1,
'read_at' => date('Y-m-d H:i:s'),
], 'id = ? AND user_id = ?', [$notificationId, $userId]);
return $this->db->fetchAll("SELECT * FROM notifications WHERE user_id = ? AND tier = 'blocking' AND is_acknowledged = 0 ORDER BY created_at ASC", [$userId]);
}
public function acknowledge(int $notificationId, int $userId): void
public function markAsRead(int $id, int $userId): void
{
$this->db->update('notifications', [
'is_read' => 1,
'read_at' => date('Y-m-d H:i:s'),
'is_acknowledged' => 1,
'acknowledged_at' => date('Y-m-d H:i:s'),
], 'id = ? AND user_id = ?', [$notificationId, $userId]);
$this->db->update('notifications', ['is_read' => 1, 'read_at' => date('Y-m-d H:i:s')], 'id = ? AND user_id = ?', [$id, $userId]);
}
public function markAllAsRead(int $userId): void
{
$this->db->update('notifications', [
'is_read' => 1,
'read_at' => date('Y-m-d H:i:s'),
], 'user_id = ? AND is_read = 0', [$userId]);
$this->db->query("UPDATE notifications SET is_read = 1, read_at = NOW() WHERE 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->create($userId, $tier, $title, $content, $linkUrl, $entityType, $entityId);
}
$this->db->update('notifications', [
'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;
interface JobInterface
{
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);
namespace Engine\Scheduler;
use Engine\Core\Container;
use Engine\Database\Connection;
final class JobRunner
{
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;
}
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())"
);
$jobs = require ROOT_PATH . '/config/scheduled_jobs.php';
$ran = 0;
foreach ($dueJobs as $jobRecord) {
$key = $jobRecord['job_key'];
if (!isset($this->jobs[$key])) continue;
foreach ($jobs as $key => $class) {
if (!class_exists($class)) 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 {
$job = $this->jobs[$key];
// Check shouldRun() if method exists
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', ['last_status' => 'running'], 'job_key = ?', [$key]);
$instance = new $class();
$instance->run();
$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] = 'success';
'last_run_at' => date('Y-m-d H:i:s'), 'last_status' => 'success', 'last_error' => null,
], 'job_key = ?', [$key]);
$ran++;
} catch (\Throwable $e) {
$this->db->update('background_jobs', [
'last_run_at' => date('Y-m-d H:i:s'),
'next_run_at' => $this->getNextRunAt($this->jobs[$key]),
'last_status' => 'failed',
'last_error' => $e->getMessage(),
], 'id = ?', [$jobRecord['id']]);
$results[$key] = 'failed: ' . $e->getMessage();
'last_run_at' => date('Y-m-d H:i:s'), 'last_status' => 'failed', 'last_error' => $e->getMessage(),
], 'job_key = ?', [$key]);
}
}
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'),
]);
}
return $ran;
}
}
\ No newline at end of file
......@@ -3,77 +3,35 @@ declare(strict_types=1);
namespace Engine\Search;
use Engine\Core\Container;
use Engine\Database\Connection;
final class SearchEngine
{
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 . '%';
$results = [];
// Cards
$cards = $this->db->fetchAll(
"SELECT c.id, c.card_key, c.title, c.board_id, b.name as board_name
FROM cards c
JOIN boards b ON b.id = c.board_id
JOIN board_members bm ON bm.board_id = b.id AND bm.user_id = ?
WHERE (c.title LIKE ? OR c.card_key LIKE ? OR c.description LIKE ?) AND c.is_archived = 0
LIMIT ?",
[$user['id'], $q, $q, $q, $limit]
"SELECT id, card_key, title, 'card' as type FROM cards WHERE (card_key LIKE ? OR title LIKE ?) AND is_archived = 0 LIMIT 10",
[$q, $q]
);
foreach ($cards as $card) {
$results[] = [
'type' => 'card',
'id' => $card['id'],
'title' => $card['card_key'] . ': ' . $card['title'],
'context' => $card['board_name'],
'url' => "/boards/{$card['board_id']}/cards/{$card['id']}",
];
}
$results = array_merge($results, $cards);
// Users (if allowed)
if (in_array($user['role'], ['super_admin', 'admin', 'project_leader'])) {
$users = $this->db->fetchAll(
"SELECT id, full_name_en, username, role FROM users WHERE (full_name_en LIKE ? OR username LIKE ?) AND status != 'terminated' LIMIT ?",
[$q, $q, $limit]
);
foreach ($users as $u) {
$results[] = [
'type' => 'user',
'id' => $u['id'],
'title' => $u['full_name_en'],
'context' => '@' . $u['username'] . ' · ' . $u['role'],
'url' => "/users/{$u['id']}",
];
}
}
// Boards
$boards = $this->db->fetchAll(
"SELECT b.id, b.name FROM boards b
JOIN board_members bm ON bm.board_id = b.id AND bm.user_id = ?
WHERE b.name LIKE ? AND b.is_archived = 0
LIMIT ?",
[$user['id'], $q, $limit]
);
foreach ($boards as $board) {
$results[] = [
'type' => 'board',
'id' => $board['id'],
'title' => $board['name'],
'context' => 'Board',
'url' => "/boards/{$board['id']}",
];
if (in_array($user['role'], ['super_admin', 'admin'])) {
$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]);
$results = array_merge($results, $users);
}
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;
final class StateDefinition
{
public static function contractorStatus(): array
{
return [
'states' => ['onboarding', 'active', 'on_pip', 'suspended', 'terminated'],
'transitions' => [
['from' => 'onboarding', 'to' => 'active'],
['from' => 'active', 'to' => 'on_pip'],
['from' => 'on_pip', 'to' => 'active'],
['from' => 'on_pip', 'to' => 'terminated'],
['from' => 'active', 'to' => 'suspended'],
['from' => 'suspended', 'to' => 'active'],
['from' => 'active', 'to' => 'terminated'],
['from' => 'suspended', 'to' => 'terminated'],
],
];
}
public static function deductionStatus(): array
{
return [
'states' => [
'draft_pending_admin', 'pending_acknowledgment', 'acknowledged',
'pending_response', 'accepted', 'disputed', 'under_review',
'applied', 'applied_no_response', 'reduced', 'dismissed'
],
'transitions' => [
['from' => 'draft_pending_admin', 'to' => 'pending_acknowledgment'],
['from' => 'draft_pending_admin', 'to' => 'dismissed'],
['from' => 'pending_acknowledgment', 'to' => 'acknowledged'],
['from' => 'acknowledged', 'to' => 'accepted'],
['from' => 'acknowledged', 'to' => 'disputed'],
['from' => 'acknowledged', 'to' => 'applied_no_response'],
['from' => 'disputed', 'to' => 'under_review'],
['from' => 'under_review', 'to' => 'applied'],
['from' => 'under_review', 'to' => 'reduced'],
['from' => 'under_review', 'to' => 'dismissed'],
['from' => 'accepted', 'to' => 'applied'],
],
];
}
public static function reportStatus(): array
{
return [
'states' => [
'draft', 'submitted', 'late', 'approved', 'approved_auto',
'flagged_vague', 'flagged_inconsistent', 'revision_requested', 'amended', 'unreported'
],
'transitions' => [
['from' => 'draft', 'to' => 'submitted'],
['from' => 'draft', 'to' => 'late'],
['from' => 'submitted', 'to' => 'approved'],
['from' => 'submitted', 'to' => 'approved_auto'],
['from' => 'submitted', 'to' => 'flagged_vague'],
['from' => 'submitted', 'to' => 'flagged_inconsistent'],
['from' => 'submitted', 'to' => 'revision_requested'],
['from' => 'late', 'to' => 'approved'],
['from' => 'revision_requested', 'to' => 'amended'],
],
];
}
public static function payrollStatus(): array
{
return [
'states' => [
'pending_calculation', 'calculated', 'under_review',
'submitted', 'approved', 'rejected', 'processing', 'paid'
],
'transitions' => [
['from' => 'pending_calculation', 'to' => 'calculated'],
['from' => 'calculated', 'to' => 'under_review'],
['from' => 'under_review', 'to' => 'submitted'],
['from' => 'submitted', 'to' => 'approved'],
['from' => 'submitted', 'to' => 'rejected'],
['from' => 'rejected', 'to' => 'under_review'],
['from' => 'approved', 'to' => 'processing'],
['from' => 'processing', 'to' => 'paid'],
],
];
}
public static function pipStatus(): array
{
return [
'states' => ['created', 'acknowledged', 'active', 'passed', 'failed'],
'transitions' => [
['from' => 'created', 'to' => 'acknowledged'],
['from' => 'acknowledged', 'to' => 'active'],
['from' => 'active', 'to' => 'passed'],
['from' => 'active', 'to' => 'failed'],
],
];
}
public static function inviteStatus(): array
{
return [
'states' => ['active', 'used', 'expired', 'revoked'],
'transitions' => [
['from' => 'active', 'to' => 'used'],
['from' => 'active', 'to' => 'expired'],
['from' => 'active', 'to' => 'revoked'],
],
];
}
public function __construct(
public readonly array $states,
public readonly array $transitions,
) {}
}
\ No newline at end of file
......@@ -3,83 +3,19 @@ declare(strict_types=1);
namespace Engine\StateMachine;
use Engine\Audit\AuditLogger;
final class StateMachine
{
private AuditLogger $audit;
private array $definitions = [];
public function __construct(AuditLogger $audit)
{
$this->audit = $audit;
}
public function define(string $entity, array $definition): void
{
$this->definitions[$entity] = $definition;
}
public function canTransition(string $entity, string $from, string $to): bool
{
$def = $this->definitions[$entity] ?? null;
if (!$def) return false;
$transitions = $def['transitions'] ?? [];
foreach ($transitions as $t) {
if ($t['from'] === $from && $t['to'] === $to) {
return true;
}
if ($t['from'] === '*' && $t['to'] === $to) {
return true;
}
}
return false;
}
public function transition(string $entity, string $from, string $to, array $context = []): bool
{
if (!$this->canTransition($entity, $from, $to)) {
return false;
}
$transitions = $this->definitions[$entity]['transitions'] ?? [];
foreach ($transitions as $t) {
$fromMatch = ($t['from'] === $from || $t['from'] === '*');
if ($fromMatch && $t['to'] === $to) {
// Execute guard if present
if (isset($t['guard']) && is_callable($t['guard'])) {
if (!($t['guard'])($context)) {
return false;
}
}
// Execute side effects
if (isset($t['onTransition']) && is_callable($t['onTransition'])) {
($t['onTransition'])($context);
}
return true;
}
}
return false;
}
public function getStates(string $entity): array
public function canTransition(StateDefinition $def, string $from, string $to): bool
{
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 = [];
$transitions = $this->definitions[$entity]['transitions'] ?? [];
foreach ($transitions as $t) {
if ($t['from'] === $currentState || $t['from'] === '*') {
$available[] = $t['to'];
}
if (!$this->canTransition($def, $from, $to)) {
throw new \RuntimeException("Invalid transition from [{$from}] to [{$to}].");
}
return array_unique($available);
return $to;
}
}
\ No newline at end of file
......@@ -3,93 +3,46 @@ declare(strict_types=1);
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
{
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->cachePath = $cachePath ?: (defined('ROOT_PATH') ? ROOT_PATH . '/storage/cache/templates' : '/tmp');
$this->basePath = $basePath ?: ROOT_PATH . '/templates';
}
/**
* Render a template with data and wrap in layout.
*/
public function render(string $template, array $data = [], ?string $layout = null): string
{
$templateFile = $this->basePath . '/' . $template . '.php';
if (!file_exists($templateFile)) {
throw new \RuntimeException("Template not found: {$template} (looked in {$templateFile})");
$file = $this->basePath . '/' . $template . '.php';
if (!file_exists($file)) {
throw new \RuntimeException("Template not found: {$template}");
}
// Render the child template first
$content = $this->renderFile($templateFile, $data);
$content = $this->renderFile($file, $data);
if ($layout === 'none') return $content;
// Determine layout
$layoutName = $layout;
if ($layoutName === null) {
// Auto-detect: use 'auth' layout for auth pages, 'app' for everything else
if (str_starts_with($template, 'auth/') || str_starts_with($template, 'errors/')) {
$layoutName = 'layouts/auth';
} else {
$layoutName = $this->defaultLayout;
}
}
if ($layoutName === 'none' || $layoutName === false || $layoutName === '') {
return $content;
$layoutName = (str_starts_with($template, 'auth/') || str_starts_with($template, 'errors/'))
? 'layouts/auth' : 'layouts/app';
}
$layoutFile = $this->basePath . '/' . $layoutName . '.php';
if (!file_exists($layoutFile)) {
// No layout file? Just return the content directly.
return $content;
}
if (!file_exists($layoutFile)) return $content;
// Render layout with content injected
$layoutData = array_merge($data, ['content' => $content]);
return $this->renderFile($layoutFile, $layoutData);
return $this->renderFile($layoutFile, array_merge($data, ['content' => $content]));
}
/**
* 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);
}
public function e(string $value): string { return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); }
/**
* Render a PHP file with extracted data and return output as string.
*/
private function renderFile(string $file, array $data): string
{
$__engine = $this;
extract($data, EXTR_SKIP);
ob_start();
try {
require $file;
} catch (\Throwable $e) {
ob_end_clean();
throw new \RuntimeException("Template render error in {$file}: " . $e->getMessage(), 0, $e);
}
try { require $file; } catch (\Throwable $e) { ob_end_clean(); throw $e; }
return ob_get_clean();
}
}
\ No newline at end of file
......@@ -3,35 +3,30 @@ declare(strict_types=1);
namespace Engine\Validation;
use Engine\Core\Container;
use Engine\Database\Connection;
final class Validator
{
private Connection $db;
private array $errors = [];
public function __construct(Connection $db)
{
$this->db = $db;
}
public function validate(array $data, array $rules): bool
{
$this->errors = [];
foreach ($rules as $field => $ruleSet) {
foreach ($rules as $field => $ruleString) {
$fieldRules = explode('|', $ruleString);
$value = $data[$field] ?? null;
$ruleList = is_string($ruleSet) ? explode('|', $ruleSet) : $ruleSet;
foreach ($ruleList as $rule) {
foreach ($fieldRules as $rule) {
$params = [];
if (str_contains($rule, ':')) {
[$rule, $paramStr] = explode(':', $rule, 2);
$params = explode(',', $paramStr);
}
$error = $this->applyRule($field, $value, $rule, $params, $data);
if ($error !== null) {
$error = $this->checkRule($field, $value, $rule, $params, $data);
if ($error) {
$this->errors[$field][] = $error;
}
}
......@@ -40,128 +35,56 @@ final class Validator
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);
return match($rule) {
'required' => ($value === null || $value === '' || $value === [])
? "{$label} is required." : null,
'string' => (!is_string($value) && $value !== null)
? "{$label} must be a string." : null,
'integer' => (!is_numeric($value) && $value !== null)
? "{$label} must be a number." : null,
'numeric' => (!is_numeric($value) && $value !== null)
? "{$label} must be numeric." : null,
'email' => ($value && !filter_var($value, FILTER_VALIDATE_EMAIL))
? "{$label} must be a valid email." : null,
'min' => (is_string($value) && strlen($value) < (int)$params[0])
? "{$label} must be at least {$params[0]} characters." : null,
'max' => (is_string($value) && strlen($value) > (int)$params[0])
? "{$label} must not exceed {$params[0]} characters." : null,
'min_value' => (is_numeric($value) && (float)$value < (float)$params[0])
? "{$label} must be at least {$params[0]}." : null,
'max_value' => (is_numeric($value) && (float)$value > (float)$params[0])
? "{$label} must not exceed {$params[0]}." : null,
'in' => ($value !== null && !in_array($value, $params, true))
? "{$label} must be one of: " . implode(', ', $params) : null,
'unique' => $this->checkUnique($field, $value, $params),
'confirmed' => ($value !== ($data["{$field}_confirmation"] ?? ($data['confirm_' . $field] ?? null)))
? "{$label} confirmation does not match." : null,
'date' => ($value && !strtotime($value))
? "{$label} must be a valid date." : null,
'regex' => ($value && !preg_match($params[0], $value))
? "{$label} format is invalid." : null,
'egyptian_phone' => ($value && !preg_match('/^01[0-9]{9}$/', $value))
? "{$label} must be a valid Egyptian phone number." : null,
'national_id' => ($value && !preg_match('/^[0-9]{14}$/', $value))
? "{$label} must be a valid 14-digit national ID." : null,
'min_age' => $this->checkMinAge($field, $value, (int)($params[0] ?? 16)),
'password_strength' => $this->checkPasswordStrength($field, $value),
'matches' => ($value !== ($data[$params[0]] ?? null))
? "{$label} must match {$params[0]}." : null,
'json' => ($value && json_decode($value) === null && json_last_error() !== JSON_ERROR_NONE)
? "{$label} must be valid JSON." : null,
'array' => (!is_array($value) && $value !== null)
? "{$label} must be an array." : null,
'boolean' => (!in_array($value, [true, false, 0, 1, '0', '1'], true) && $value !== null)
? "{$label} must be true or false." : null,
return match ($rule) {
'required' => ($value === null || $value === '') ? "{$label} is required." : null,
'string' => (!is_string($value) && $value !== null) ? "{$label} must be a string." : null,
'integer' => (!is_numeric($value) && $value !== null) ? "{$label} must be an integer." : null,
'numeric' => (!is_numeric($value) && $value !== null) ? "{$label} must be numeric." : 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,
'max' => (strlen((string)$value) > (int)$params[0]) ? "{$label} must be at most {$params[0]} characters." : 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,
'matches' => ($value !== ($data[$params[0]] ?? null)) ? "{$label} must match {$params[0]}." : null,
'unique' => $this->checkUnique($value, $params, $label),
'password_strength' => $this->checkPasswordStrength($value, $label),
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;
$table = $params[0] ?? '';
$column = $params[1] ?? $field;
$exceptId = $params[2] ?? null;
$sql = "SELECT COUNT(*) FROM `{$table}` WHERE `{$column}` = ?";
$bindings = [$value];
if ($exceptId) {
$sql .= " AND id != ?";
$bindings[] = $exceptId;
if (!$value || count($params) < 2) return null;
try {
$db = Container::getInstance()->resolve(Connection::class);
$exists = $db->fetchColumn("SELECT COUNT(*) FROM `{$params[0]}` WHERE `{$params[1]}` = ?", [$value]);
return $exists > 0 ? "{$label} is already taken." : null;
} catch (\Throwable $e) {
return null;
}
$count = (int)$this->db->fetchColumn($sql, $bindings);
return $count > 0 ? str_replace('_', ' ', $field) . " already exists." : null;
}
private function checkMinAge(string $field, mixed $value, int $minAge): ?string
{
if (!$value) return null;
$dob = new \DateTime($value);
$now = new \DateTime();
$age = $now->diff($dob)->y;
return $age < $minAge ? str_replace('_', ' ', $field) . " must indicate at least {$minAge} years of age." : null;
}
private function checkPasswordStrength(string $field, mixed $value): ?string
private function checkPasswordStrength(mixed $value, string $label): ?string
{
if (!$value) return null;
if (strlen($value) < 10) return "Password must be at least 10 characters.";
if (!preg_match('/[A-Z]/', $value)) return "Password must contain at least one uppercase letter.";
if (!preg_match('/[a-z]/', $value)) return "Password must contain at least one lowercase letter.";
if (!preg_match('/[0-9]/', $value)) return "Password must contain at least one number.";
if (!preg_match('/[^A-Za-z0-9]/', $value)) return "Password must contain at least one special character.";
if (!preg_match('/[A-Z]/', $value)) return "Password must contain an uppercase letter.";
if (!preg_match('/[a-z]/', $value)) return "Password must contain a lowercase letter.";
if (!preg_match('/[0-9]/', $value)) return "Password must contain a number.";
return null;
}
public function errors(): array
{
return $this->errors;
}
public function errors(): array { return $this->errors; }
public function firstError(): ?string
public function firstError(): string
{
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);
namespace Middleware;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Core\{Request, Response, Container};
use Engine\Database\Connection;
use Engine\Auth\PasswordHasher;
final class ApiKeyAuthMiddleware
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function handle(Request $request, callable $next): Response
{
$authHeader = $request->header('Authorization', '');
$apiKey = null;
$authHeader = $request->header('authorization', '');
$apiKey = $request->header('x-api-key', '');
if (str_starts_with($authHeader, 'Bearer ')) {
$apiKey = substr($authHeader, 7);
}
if (!$apiKey) {
$apiKey = $request->header('X-API-Key', '');
}
if (!$apiKey) {
return Response::json(['error' => 'API key required. Provide via Authorization: Bearer <key> or X-API-Key header.'], 401);
return Response::json(['error' => 'API key required'], 401);
}
$db = Container::getInstance()->resolve(Connection::class);
$prefix = substr($apiKey, 0, 8);
$keyRecord = $this->db->fetchOne(
"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']]);
$candidates = $db->fetchAll("SELECT * FROM api_keys WHERE key_prefix = ? AND revoked_at IS NULL", [$prefix]);
// Resolve the user who created this key to act on their behalf
$user = $this->db->fetchOne("SELECT * FROM users WHERE id = ? AND is_active = 1", [$keyRecord['created_by_id']]);
if (!$user) {
return Response::json(['error' => 'API key owner account is disabled.'], 403);
}
// 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);
$matched = null;
foreach ($candidates as $candidate) {
if (password_verify($apiKey, $candidate['key_hash'])) {
$matched = $candidate;
break;
}
}
if ($scope === 'read_write' && $method === 'DELETE') {
// read_write can't delete, only admin scope can
$uri = $request->uri();
if (!str_contains($uri, '/api/auth/')) {
return Response::json(['error' => 'DELETE operations require admin-scoped API key.'], 403);
}
if (!$matched) {
return Response::json(['error' => 'Invalid API key'], 401);
}
$request->setUser($user);
$request->setAttribute('api_key_id', $keyRecord['id']);
$request->setAttribute('api_key_scope', $scope);
$request->setAttribute('is_api_request', true);
$db->update('api_keys', ['last_used_at' => date('Y-m-d H:i:s')], 'id = ?', [$matched['id']]);
$owner = $db->fetchOne("SELECT * FROM users WHERE id = ?", [$matched['created_by_id']]);
if ($owner) $request->setUser($owner);
return $next($request);
}
......
......@@ -3,42 +3,12 @@ declare(strict_types=1);
namespace Middleware;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Core\Container;
use Engine\Audit\AuditLogger;
use Engine\Core\{Request, Response};
final class AuditMiddleware
{
private AuditLogger $audit;
public function __construct()
{
$this->audit = Container::getInstance()->resolve(AuditLogger::class);
}
public function handle(Request $request, callable $next): Response
{
$response = $next($request);
$user = $request->user();
$method = $request->method();
if (in_array($method, ['POST', 'PUT', 'DELETE'])) {
$this->audit->log(
$user,
$method,
'http_request',
null,
'system',
$request->uri(),
null,
null,
$request->ip(),
$request->userAgent()
);
}
return $response;
return $next($request);
}
}
\ No newline at end of file
......@@ -3,32 +3,26 @@ declare(strict_types=1);
namespace Middleware;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Core\Container;
use Engine\Core\{Request, Response, Container};
use Engine\Auth\SessionManager;
final class AuthenticationMiddleware
{
private SessionManager $sessions;
public function __construct()
{
$this->sessions = Container::getInstance()->resolve(SessionManager::class);
}
public function handle(Request $request, callable $next): Response
{
$user = $this->sessions->validate();
$sessions = Container::getInstance()->resolve(SessionManager::class);
$user = $sessions->validate();
if (!$user) {
if ($request->wantsJson()) {
return Response::json(['error' => 'Unauthenticated'], 401);
return Response::json(['error' => 'Authentication required'], 401);
}
return Response::redirect('/login');
}
$request->setAttribute('user', $user);
$request->setUser($user);
$sessions->generateCsrfToken();
return $next($request);
}
}
\ No newline at end of file
......@@ -3,42 +3,23 @@ declare(strict_types=1);
namespace Middleware;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Core\Container;
use Engine\Core\{Request, Response, Container};
use Engine\Notifications\NotificationManager;
final class BlockingNotificationMiddleware
{
private NotificationManager $notifications;
public function __construct()
{
$this->notifications = Container::getInstance()->resolve(NotificationManager::class);
}
public function handle(Request $request, callable $next): Response
{
$user = $request->user();
if (!$user) {
return $next($request);
}
if (!$user || $request->wantsJson()) return $next($request);
// Allow notification endpoints through
$uri = $request->uri();
if (str_starts_with($uri, '/api/notifications') || str_starts_with($uri, '/notifications') || str_starts_with($uri, '/sse') || str_starts_with($uri, '/login') || str_starts_with($uri, '/logout')) {
return $next($request);
}
$path = $request->path();
$exempt = ['/notifications/blocking', '/notifications', '/logout', '/password/change', '/sse/stream'];
if (in_array($path, $exempt) || str_starts_with($path, '/api/')) return $next($request);
if ($this->notifications->hasBlocking($user['id'])) {
if ($request->wantsJson()) {
$blocking = $this->notifications->getBlockingUnacknowledged($user['id']);
return Response::json([
'blocked' => true,
'notification' => $blocking[0] ?? null,
], 403);
}
// For HTML requests, redirect to blocking notification page
$notif = Container::getInstance()->resolve(NotificationManager::class);
$blocking = $notif->getBlockingUnacknowledged($user['id']);
if (!empty($blocking)) {
return Response::redirect('/notifications/blocking');
}
......
......@@ -3,24 +3,20 @@ declare(strict_types=1);
namespace Middleware;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Core\{Request, Response};
final class CORSMiddleware
{
public function handle(Request $request, callable $next): Response
{
if ($request->method() === 'OPTIONS') {
return (new Response('', 204))
->withHeader('Access-Control-Allow-Origin', '*')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-CSRF-Token, X-Requested-With')
->withHeader('Access-Control-Max-Age', '86400');
return Response::make('', 204, [
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-CSRF-Token, X-Requested-With',
'Access-Control-Max-Age' => '86400',
]);
}
$response = $next($request);
return $response
->withHeader('Access-Control-Allow-Origin', '*')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
return $next($request);
}
}
\ No newline at end of file
......@@ -3,42 +3,27 @@ declare(strict_types=1);
namespace Middleware;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Core\{Request, Response, Container};
use Engine\Auth\SessionManager;
final class CSRFMiddleware
{
public function handle(Request $request, callable $next): Response
{
if (in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'])) {
$this->ensureToken();
return $next($request);
}
$token = $request->input('_csrf_token') ?? $request->header('x-csrf-token');
$sessionToken = $_COOKIE['csrf_token'] ?? '';
$sessions = Container::getInstance()->resolve(SessionManager::class);
$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()) {
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);
}
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);
namespace Middleware;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Core\{Request, Response};
final class JsonBodyParserMiddleware
{
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);
}
}
\ No newline at end of file
......@@ -3,37 +3,14 @@ declare(strict_types=1);
namespace Middleware;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Core\{Request, Response};
final class SecurityHeadersMiddleware
{
public function handle(Request $request, callable $next): Response
{
$response = $next($request);
$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);
// Headers are set in .htaccess / Apache config for static responses
return $response;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/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');
use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/adjustments', [\Modules\Adjustments\Controllers\AdjustmentController::class, 'index']);
Router::post('/adjustments', [\Modules\Adjustments\Controllers\AdjustmentController::class, 'create']);
Router::post('/adjustments/{id}/approve', [\Modules\Adjustments\Controllers\AdjustmentController::class, 'approve']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
Router::group('/analytics', ['middleware' => ['auth', 'audit']], function () {
Router::get('/', [\Modules\Analytics\Controllers\AnalyticsController::class, 'dashboard']);
Router::get('/report-builder', [\Modules\Analytics\Controllers\ReportBuilderController::class, 'index']);
Router::get('/report-builder/sources', [\Modules\Analytics\Controllers\ReportBuilderController::class, 'sources']);
Router::post('/report-builder/execute', [\Modules\Analytics\Controllers\ReportBuilderController::class, 'execute']);
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
Router::get('/analytics', [\Modules\Analytics\Controllers\AnalyticsController::class, 'dashboard']);
Router::get('/analytics/report-builder', [\Modules\Analytics\Controllers\ReportBuilderController::class, 'index']);
Router::get('/analytics/report-builder/sources', [\Modules\Analytics\Controllers\ReportBuilderController::class, 'sources']);
Router::post('/analytics/report-builder/execute', [\Modules\Analytics\Controllers\ReportBuilderController::class, 'execute']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
Router::group('/api-keys', ['middleware' => ['auth', 'audit']], function () {
Router::get('/', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'index']);
Router::post('/', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'create']);
Router::post('/{keyId}/revoke', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'revoke']);
Router::delete('/{keyId}', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'delete']);
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/api-keys', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'index']);
Router::post('/api-keys', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'create']);
Router::post('/api-keys/{keyId}/revoke', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'revoke']);
Router::delete('/api-keys/{keyId}', [\Modules\ApiKeys\Controllers\ApiKeyController::class, 'delete']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
Router::group('/audit-trail', ['middleware' => ['auth']], function () {
Router::get('/', [\Modules\AuditTrail\Controllers\AuditTrailController::class, 'index']);
Router::get('/stats', [\Modules\AuditTrail\Controllers\AuditTrailController::class, 'stats']);
Router::get('/{entryId}', [\Modules\AuditTrail\Controllers\AuditTrailController::class, 'show']);
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/audit-trail', [\Modules\AuditTrail\Controllers\AuditTrailController::class, 'index']);
Router::get('/audit-trail/stats', [\Modules\AuditTrail\Controllers\AuditTrailController::class, 'stats']);
Router::get('/audit-trail/{entryId}', [\Modules\AuditTrail\Controllers\AuditTrailController::class, 'show']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->get('/login', Modules\Auth\Controllers\LoginController::class, 'showForm');
$router->post('/login', Modules\Auth\Controllers\LoginController::class, 'login');
$router->group(['middleware' => [Middleware\AuthenticationMiddleware::class]], function ($router) {
$router->post('/logout', Modules\Auth\Controllers\LogoutController::class, 'logout');
$router->get('/password/change', Modules\Auth\Controllers\PasswordController::class, 'showChangeForm');
$router->post('/password/change', Modules\Auth\Controllers\PasswordController::class, 'change');
$router->post('/users/{userId}/reset-password', Modules\Auth\Controllers\PasswordController::class, 'resetForUser');
use Engine\Core\Router;
Router::get('/login', [\Modules\Auth\Controllers\LoginController::class, 'showForm']);
Router::post('/login', [\Modules\Auth\Controllers\LoginController::class, 'login']);
Router::group('', ['middleware' => ['auth']], function () {
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
use Engine\Core\Router;
use Modules\BoardTemplates\Controllers\BoardTemplateController;
return function (Router $router) {
$router->get('/board-templates', [BoardTemplateController::class, 'index']);
$router->post('/board-templates', [BoardTemplateController::class, 'create']);
$router->post('/board-templates/from-board/{boardId}', [BoardTemplateController::class, 'saveFromBoard']);
$router->delete('/board-templates/{templateId}', [BoardTemplateController::class, 'delete']);
$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
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/board-templates', [\Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'index']);
Router::post('/board-templates', [\Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'create']);
Router::post('/board-templates/from-board/{boardId}', [\Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'saveFromBoard']);
Router::delete('/board-templates/{templateId}', [\Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'delete']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/boards',
'middleware' => [
Middleware\AuthenticationMiddleware::class,
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');
use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
Router::get('/boards', [\Modules\Boards\Controllers\BoardController::class, 'index']);
Router::post('/boards', [\Modules\Boards\Controllers\BoardController::class, 'create']);
Router::get('/boards/{boardId}', [\Modules\Boards\Controllers\BoardController::class, 'show']);
Router::put('/boards/{boardId}', [\Modules\Boards\Controllers\BoardController::class, 'update']);
Router::post('/boards/{boardId}/archive', [\Modules\Boards\Controllers\BoardController::class, 'archive']);
Router::post('/boards/{boardId}/members', [\Modules\Boards\Controllers\BoardController::class, 'addMember']);
Router::delete('/boards/{boardId}/members/{memberId}', [\Modules\Boards\Controllers\BoardController::class, 'removeMember']);
Router::post('/boards/{boardId}/columns', [\Modules\Boards\Controllers\BoardController::class, 'addColumn']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
use Modules\CardTemplates\Controllers\CardTemplateController;
return function (Router $router) {
$router->get('/card-templates', [CardTemplateController::class, 'index']);
$router->post('/card-templates', [CardTemplateController::class, 'create']);
$router->put('/card-templates/{templateId}', [CardTemplateController::class, 'update']);
$router->delete('/card-templates/{templateId}', [CardTemplateController::class, 'delete']);
$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
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/card-templates', [\Modules\CardTemplates\Controllers\CardTemplateController::class, 'index']);
Router::post('/card-templates', [\Modules\CardTemplates\Controllers\CardTemplateController::class, 'create']);
Router::put('/card-templates/{templateId}', [\Modules\CardTemplates\Controllers\CardTemplateController::class, 'update']);
Router::delete('/card-templates/{templateId}', [\Modules\CardTemplates\Controllers\CardTemplateController::class, 'delete']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '',
'middleware' => [
Middleware\AuthenticationMiddleware::class,
Middleware\CSRFMiddleware::class,
Middleware\BlockingNotificationMiddleware::class,
]
], function ($router) {
$router->post('/boards/{boardId}/cards', Modules\Cards\Controllers\CardController::class, 'create');
$router->get('/cards/{cardId}', Modules\Cards\Controllers\CardController::class, 'show');
$router->post('/cards/{cardId}', Modules\Cards\Controllers\CardController::class, 'update');
$router->post('/cards/{cardId}/move', Modules\Cards\Controllers\CardController::class, 'move');
$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');
use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
Router::get('/cards/{cardId}', [\Modules\Cards\Controllers\CardController::class, 'show']);
Router::post('/boards/{boardId}/cards', [\Modules\Cards\Controllers\CardController::class, 'create']);
Router::put('/cards/{cardId}', [\Modules\Cards\Controllers\CardController::class, 'update']);
Router::post('/cards/{cardId}/move', [\Modules\Cards\Controllers\CardController::class, 'move']);
Router::post('/cards/{cardId}/assign', [\Modules\Cards\Controllers\CardController::class, 'assign']);
Router::post('/cards/{cardId}/comments', [\Modules\Cards\Controllers\CardController::class, 'addComment']);
Router::put('/cards/{cardId}/comments/{commentId}', [\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::delete('/cards/{cardId}/labels/{labelId}', [\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
use Engine\Core\Router;
use Modules\Contracts\Controllers\ContractController;
use Modules\Contracts\Controllers\PolicyController;
use Modules\Contracts\Controllers\NoticeController;
return function (Router $router) {
$router->get('/contracts', [ContractController::class, 'index']);
$router->get('/contracts/{contractId}', [ContractController::class, 'show']);
$router->get('/policies', [PolicyController::class, 'index']);
$router->get('/policies/{policyId}', [PolicyController::class, 'show']);
$router->post('/policies', [PolicyController::class, 'create']);
$router->post('/policies/{policyId}/publish', [PolicyController::class, 'publish']);
$router->post('/policies/versions/{versionId}/acknowledge', [PolicyController::class, 'acknowledge']);
$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
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/contracts', [\Modules\Contracts\Controllers\ContractController::class, 'index']);
Router::get('/contracts/{contractId}', [\Modules\Contracts\Controllers\ContractController::class, 'show']);
Router::get('/policies', [\Modules\Contracts\Controllers\PolicyController::class, 'index']);
Router::get('/policies/{policyId}', [\Modules\Contracts\Controllers\PolicyController::class, 'show']);
Router::post('/policies', [\Modules\Contracts\Controllers\PolicyController::class, 'create']);
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('/notices', [\Modules\Contracts\Controllers\NoticeController::class, 'index']);
Router::post('/notices', [\Modules\Contracts\Controllers\NoticeController::class, 'create']);
Router::post('/notices/{noticeId}/acknowledge', [\Modules\Contracts\Controllers\NoticeController::class, 'acknowledgeNotice']);
Router::delete('/notices/{noticeId}', [\Modules\Contracts\Controllers\NoticeController::class, 'delete']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
Router::group('/control-panel', ['middleware' => ['auth', 'audit']], function () {
Router::get('/', [\Modules\ControlPanel\Controllers\ControlPanelController::class, 'index']);
Router::get('/entities', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'entities']);
Router::get('/{entity}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'list']);
Router::get('/{entity}/{id}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'show']);
Router::put('/{entity}/{id}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'update']);
Router::delete('/{entity}/{id}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'delete']);
Router::post('/{entity}/bulk-delete', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'bulkDelete']);
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/control-panel', [\Modules\ControlPanel\Controllers\ControlPanelController::class, 'index']);
Router::get('/control-panel/entities', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'entities']);
Router::get('/control-panel/{entity}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'list']);
Router::get('/control-panel/{entity}/{id}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'show']);
Router::put('/control-panel/{entity}/{id}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'update']);
Router::delete('/control-panel/{entity}/{id}', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'delete']);
Router::post('/control-panel/{entity}/bulk-delete', [\Modules\ControlPanel\Controllers\EntityCrudController::class, 'bulkDelete']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '',
'middleware' => [
Middleware\AuthenticationMiddleware::class,
Middleware\CSRFMiddleware::class,
Middleware\BlockingNotificationMiddleware::class,
]
], function ($router) {
$router->get('/dashboard', Modules\Dashboard\Controllers\DashboardController::class, 'index');
$router->get('/', Modules\Dashboard\Controllers\DashboardController::class, 'index');
use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
Router::get('/dashboard', [\Modules\Dashboard\Controllers\DashboardController::class, 'index']);
Router::get('/', [\Modules\Dashboard\Controllers\DashboardController::class, 'index']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
use Modules\DeductionPresets\Controllers\DeductionPresetController;
return function (Router $router) {
$router->get('/deduction-presets', [DeductionPresetController::class, 'index']);
$router->post('/deduction-presets', [DeductionPresetController::class, 'create']);
$router->put('/deduction-presets/{presetId}', [DeductionPresetController::class, 'update']);
$router->delete('/deduction-presets/{presetId}', [DeductionPresetController::class, 'delete']);
$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
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/deduction-presets', [\Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'index']);
Router::post('/deduction-presets', [\Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'create']);
Router::put('/deduction-presets/{presetId}', [\Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'update']);
Router::delete('/deduction-presets/{presetId}', [\Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'delete']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/deductions',
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class]
], 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');
use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
Router::get('/deductions', [\Modules\Deductions\Controllers\DeductionController::class, 'index']);
Router::get('/deductions/{deductionId}', [\Modules\Deductions\Controllers\DeductionController::class, 'show']);
Router::post('/deductions', [\Modules\Deductions\Controllers\DeductionController::class, 'initiate']);
Router::post('/deductions/{deductionId}/acknowledge', [\Modules\Deductions\Controllers\DeductionController::class, 'acknowledge']);
Router::post('/deductions/{deductionId}/respond', [\Modules\Deductions\Controllers\DeductionController::class, 'respond']);
Router::post('/deductions/{deductionId}/review', [\Modules\Deductions\Controllers\DeductionController::class, 'reviewDecision']);
Router::post('/deductions/{deductionId}/admin-review', [\Modules\Deductions\Controllers\DeductionController::class, 'adminReview']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
use Modules\Evaluations\Controllers\EvaluationController;
use Modules\Evaluations\Controllers\EvaluationCycleController;
return function (Router $router) {
$router->get('/evaluations/mine', [EvaluationController::class, 'myEvaluations']);
$router->get('/evaluations/pending', [EvaluationController::class, 'pending']);
$router->get('/evaluations/compiled/{compiledId}', [EvaluationController::class, 'showCompiled']);
$router->get('/evaluations/{evaluationId}/technical', [EvaluationController::class, 'technicalForm']);
$router->post('/evaluations/{evaluationId}/technical', [EvaluationController::class, 'submitTechnical']);
$router->get('/evaluations/{evaluationId}/professional', [EvaluationController::class, 'professionalForm']);
$router->post('/evaluations/{evaluationId}/professional', [EvaluationController::class, 'submitProfessional']);
$router->post('/evaluations/compiled/{compiledId}/acknowledge', [EvaluationController::class, 'acknowledge']);
$router->post('/evaluations/compiled/{compiledId}/respond', [EvaluationController::class, 'respond']);
$router->get('/evaluations/cycles', [EvaluationCycleController::class, 'index']);
$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
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
Router::get('/evaluations', [\Modules\Evaluations\Controllers\EvaluationController::class, 'myEvaluations']);
Router::get('/evaluations/pending', [\Modules\Evaluations\Controllers\EvaluationController::class, 'pending']);
Router::get('/evaluations/compiled/{compiledId}', [\Modules\Evaluations\Controllers\EvaluationController::class, 'showCompiled']);
Router::get('/evaluations/{evaluationId}/technical', [\Modules\Evaluations\Controllers\EvaluationController::class, 'technicalForm']);
Router::post('/evaluations/{evaluationId}/technical', [\Modules\Evaluations\Controllers\EvaluationController::class, 'submitTechnical']);
Router::get('/evaluations/{evaluationId}/professional', [\Modules\Evaluations\Controllers\EvaluationController::class, 'professionalForm']);
Router::post('/evaluations/{evaluationId}/professional', [\Modules\Evaluations\Controllers\EvaluationController::class, 'submitProfessional']);
Router::post('/evaluations/compiled/{compiledId}/acknowledge', [\Modules\Evaluations\Controllers\EvaluationController::class, 'acknowledge']);
Router::post('/evaluations/compiled/{compiledId}/respond', [\Modules\Evaluations\Controllers\EvaluationController::class, 'respond']);
Router::get('/evaluations/cycles', [\Modules\Evaluations\Controllers\EvaluationCycleController::class, 'index']);
Router::get('/evaluations/cycles/{cycleId}', [\Modules\Evaluations\Controllers\EvaluationCycleController::class, 'show']);
Router::post('/evaluations/cycles', [\Modules\Evaluations\Controllers\EvaluationCycleController::class, 'create']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
Router::group('/export', ['middleware' => ['auth', 'audit']], function () {
Router::post('/csv', [\Modules\Export\Controllers\ExportController::class, 'exportCsv']);
Router::get('/contractor/{userId}', [\Modules\Export\Controllers\ExportController::class, 'exportContractorZip']);
Router::post('/audit-trail', [\Modules\Export\Controllers\ExportController::class, 'exportAuditTrail']);
Router::get('/payslip/{payrollId}', [\Modules\Export\Controllers\PdfExportController::class, 'payslip']);
Router::get('/evaluation/{compiledId}', [\Modules\Export\Controllers\PdfExportController::class, 'evaluationReport']);
Router::group('', ['middleware' => ['auth']], function () {
Router::post('/export/csv', [\Modules\Export\Controllers\ExportController::class, 'exportCsv']);
Router::get('/export/contractor/{userId}', [\Modules\Export\Controllers\ExportController::class, 'exportContractorZip']);
Router::post('/export/audit-trail', [\Modules\Export\Controllers\ExportController::class, 'exportAuditTrail']);
Router::get('/export/payslip/{payrollId}', [\Modules\Export\Controllers\PdfExportController::class, 'payslip']);
Router::get('/export/evaluation/{compiledId}', [\Modules\Export\Controllers\PdfExportController::class, 'evaluationReport']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
use Modules\Holidays\Controllers\HolidayController;
return function (Router $router) {
$router->get('/holidays', [HolidayController::class, 'index']);
$router->post('/holidays', [HolidayController::class, 'create']);
$router->put('/holidays/{id}', [HolidayController::class, 'update']);
$router->delete('/holidays/{id}', [HolidayController::class, 'delete']);
$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
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/holidays', [\Modules\Holidays\Controllers\HolidayController::class, 'index']);
Router::post('/holidays', [\Modules\Holidays\Controllers\HolidayController::class, 'create']);
Router::put('/holidays/{id}', [\Modules\Holidays\Controllers\HolidayController::class, 'update']);
Router::delete('/holidays/{id}', [\Modules\Holidays\Controllers\HolidayController::class, 'delete']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/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');
use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/labels', [\Modules\Labels\Controllers\LabelController::class, 'index']);
Router::post('/labels', [\Modules\Labels\Controllers\LabelController::class, 'create']);
Router::put('/labels/{labelId}', [\Modules\Labels\Controllers\LabelController::class, 'update']);
Router::delete('/labels/{labelId}', [\Modules\Labels\Controllers\LabelController::class, 'delete']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
use Modules\LearningGoals\Controllers\LearningGoalController;
use Modules\LearningGoals\Controllers\CompetencyController;
return function (Router $router) {
$router->get('/learning-goals', [LearningGoalController::class, 'index']);
$router->post('/learning-goals', [LearningGoalController::class, 'create']);
$router->put('/learning-goals/{goalId}', [LearningGoalController::class, 'update']);
$router->post('/learning-goals/{goalId}/assess', [LearningGoalController::class, 'assess']);
$router->delete('/learning-goals/{goalId}', [LearningGoalController::class, 'delete']);
$router->get('/competency/areas', [CompetencyController::class, 'areas']);
$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
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/learning-goals', [\Modules\LearningGoals\Controllers\LearningGoalController::class, 'index']);
Router::post('/learning-goals', [\Modules\LearningGoals\Controllers\LearningGoalController::class, 'create']);
Router::put('/learning-goals/{goalId}', [\Modules\LearningGoals\Controllers\LearningGoalController::class, 'update']);
Router::post('/learning-goals/{goalId}/assess', [\Modules\LearningGoals\Controllers\LearningGoalController::class, 'assess']);
Router::delete('/learning-goals/{goalId}', [\Modules\LearningGoals\Controllers\LearningGoalController::class, 'delete']);
Router::get('/competencies', [\Modules\LearningGoals\Controllers\CompetencyController::class, 'areas']);
Router::get('/competencies/{userId}/profile', [\Modules\LearningGoals\Controllers\CompetencyController::class, 'profile']);
Router::post('/competencies/{userId}/assess', [\Modules\LearningGoals\Controllers\CompetencyController::class, 'submitAssessment']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
use Modules\Meetings\Controllers\MeetingController;
return function (Router $router) {
$router->get('/meetings', [MeetingController::class, 'index']);
$router->get('/meetings/{meetingId}', [MeetingController::class, 'show']);
$router->post('/meetings', [MeetingController::class, 'create']);
$router->put('/meetings/{meetingId}', [MeetingController::class, 'update']);
$router->post('/meetings/{meetingId}/notes', [MeetingController::class, 'addNotes']);
$router->delete('/meetings/{meetingId}', [MeetingController::class, 'delete']);
$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
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/meetings', [\Modules\Meetings\Controllers\MeetingController::class, 'index']);
Router::get('/meetings/{meetingId}', [\Modules\Meetings\Controllers\MeetingController::class, 'show']);
Router::post('/meetings', [\Modules\Meetings\Controllers\MeetingController::class, 'create']);
Router::put('/meetings/{meetingId}', [\Modules\Meetings\Controllers\MeetingController::class, 'update']);
Router::post('/meetings/{meetingId}/notes', [\Modules\Meetings\Controllers\MeetingController::class, 'addNotes']);
Router::delete('/meetings/{meetingId}', [\Modules\Meetings\Controllers\MeetingController::class, 'delete']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/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');
use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
Router::get('/messages', [\Modules\Messaging\Controllers\MessagingController::class, 'conversations']);
Router::get('/messages/{conversationId}', [\Modules\Messaging\Controllers\MessagingController::class, 'messages']);
Router::post('/messages/conversations', [\Modules\Messaging\Controllers\MessagingController::class, 'startConversation']);
Router::post('/messages/{conversationId}', [\Modules\Messaging\Controllers\MessagingController::class, 'sendMessage']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/notifications',
'middleware' => [Middleware\AuthenticationMiddleware::class]
], function ($router) {
$router->get('/', Modules\Notifications\Controllers\NotificationController::class, 'index');
$router->get('/recent', Modules\Notifications\Controllers\NotificationController::class, 'recent');
$router->get('/blocking', Modules\Notifications\Controllers\NotificationController::class, 'showBlocking');
$router->post('/{id}/read', Modules\Notifications\Controllers\NotificationController::class, 'markRead');
$router->post('/read-all', Modules\Notifications\Controllers\NotificationController::class, 'markAllRead');
$router->post('/{id}/acknowledge', Modules\Notifications\Controllers\NotificationController::class, 'acknowledge');
use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/notifications', [\Modules\Notifications\Controllers\NotificationController::class, 'index']);
Router::get('/notifications/recent', [\Modules\Notifications\Controllers\NotificationController::class, 'recent']);
Router::get('/notifications/blocking', [\Modules\Notifications\Controllers\NotificationController::class, 'showBlocking']);
Router::post('/notifications/{id}/read', [\Modules\Notifications\Controllers\NotificationController::class, 'markRead']);
Router::post('/notifications/read-all', [\Modules\Notifications\Controllers\NotificationController::class, 'markAllRead']);
Router::post('/notifications/{id}/acknowledge', [\Modules\Notifications\Controllers\NotificationController::class, 'acknowledge']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
Router::group('/offboarding', ['middleware' => ['auth', 'csrf']], function () {
Router::post('/terminate', [\Modules\Offboarding\Controllers\OffboardingController::class, 'initiate']);
Router::get('/settlement/{userId}', [\Modules\Offboarding\Controllers\OffboardingController::class, 'calculateFinalSettlement']);
Router::group('', ['middleware' => ['auth']], function () {
Router::post('/offboarding', [\Modules\Offboarding\Controllers\OffboardingController::class, 'initiate']);
Router::get('/offboarding/{userId}/settlement', [\Modules\Offboarding\Controllers\OffboardingController::class, 'calculateFinalSettlement']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
Router::group('/invites', ['middleware' => ['auth', 'csrf']], function () {
Router::get('', [\Modules\Onboarding\Controllers\InviteController::class, 'index']);
Router::post('', [\Modules\Onboarding\Controllers\InviteController::class, 'create']);
Router::post('/{inviteId}/revoke', [\Modules\Onboarding\Controllers\InviteController::class, 'revoke']);
Router::delete('/{inviteId}', [\Modules\Onboarding\Controllers\InviteController::class, 'delete']);
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/invites', [\Modules\Onboarding\Controllers\InviteController::class, 'index']);
Router::post('/invites', [\Modules\Onboarding\Controllers\InviteController::class, 'create']);
Router::post('/invites/{inviteId}/revoke', [\Modules\Onboarding\Controllers\InviteController::class, 'revoke']);
Router::delete('/invites/{inviteId}', [\Modules\Onboarding\Controllers\InviteController::class, 'delete']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
use Modules\PIPs\Controllers\PIPController;
return function (Router $router) {
$router->get('/pips', [PIPController::class, 'index']);
$router->get('/pips/{pipId}', [PIPController::class, 'show']);
$router->post('/pips', [PIPController::class, 'create']);
$router->post('/pips/{pipId}/acknowledge', [PIPController::class, 'acknowledge']);
$router->post('/pips/{pipId}/checkins/{checkinId}', [PIPController::class, 'logCheckin']);
$router->post('/pips/{pipId}/decide', [PIPController::class, 'decide']);
$router->delete('/pips/{pipId}', [PIPController::class, 'delete']);
$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
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
Router::get('/pips', [\Modules\PIPs\Controllers\PIPController::class, 'index']);
Router::get('/pips/{pipId}', [\Modules\PIPs\Controllers\PIPController::class, 'show']);
Router::post('/pips', [\Modules\PIPs\Controllers\PIPController::class, 'create']);
Router::post('/pips/{pipId}/acknowledge', [\Modules\PIPs\Controllers\PIPController::class, 'acknowledge']);
Router::post('/pips/{pipId}/checkins/{checkinId}', [\Modules\PIPs\Controllers\PIPController::class, 'logCheckin']);
Router::post('/pips/{pipId}/decide', [\Modules\PIPs\Controllers\PIPController::class, 'decide']);
Router::delete('/pips/{pipId}', [\Modules\PIPs\Controllers\PIPController::class, 'delete']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/payroll',
'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');
use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
Router::get('/payroll', [\Modules\Payroll\Controllers\PayrollController::class, 'index']);
Router::post('/payroll/calculate', [\Modules\Payroll\Controllers\PayrollController::class, 'calculate']);
Router::post('/payroll/{payrollId}/submit', [\Modules\Payroll\Controllers\PayrollController::class, 'submit']);
Router::post('/payroll/{payrollId}/approve', [\Modules\Payroll\Controllers\PayrollController::class, 'approve']);
Router::post('/payroll/{payrollId}/reject', [\Modules\Payroll\Controllers\PayrollController::class, 'reject']);
Router::post('/payroll/{payrollId}/paid', [\Modules\Payroll\Controllers\PayrollController::class, 'markPaid']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
use Modules\RecurringCards\Controllers\RecurringCardController;
return function (Router $router) {
$router->get('/recurring-cards', [RecurringCardController::class, 'index']);
$router->post('/recurring-cards', [RecurringCardController::class, 'create']);
$router->put('/recurring-cards/{defId}', [RecurringCardController::class, 'update']);
$router->delete('/recurring-cards/{defId}', [RecurringCardController::class, 'delete']);
$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
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/recurring-cards', [\Modules\RecurringCards\Controllers\RecurringCardController::class, 'index']);
Router::post('/recurring-cards', [\Modules\RecurringCards\Controllers\RecurringCardController::class, 'create']);
Router::put('/recurring-cards/{defId}', [\Modules\RecurringCards\Controllers\RecurringCardController::class, 'update']);
Router::delete('/recurring-cards/{defId}', [\Modules\RecurringCards\Controllers\RecurringCardController::class, 'delete']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/reports',
'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');
use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
Router::get('/reports/submit', [\Modules\Reports\Controllers\ReportController::class, 'submitForm']);
Router::post('/reports/submit', [\Modules\Reports\Controllers\ReportController::class, 'submit']);
Router::get('/reports/review', [\Modules\Reports\Controllers\ReportController::class, 'review']);
Router::post('/reports/{reportId}/review', [\Modules\Reports\Controllers\ReportController::class, 'reviewAction']);
Router::post('/reports/bulk-approve', [\Modules\Reports\Controllers\ReportController::class, 'bulkApprove']);
Router::get('/reports/history', [\Modules\Reports\Controllers\ReportController::class, 'history']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
use Modules\Dashboard\Controllers\DashboardController;
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
// Salary calculations are accessed via API and calculator engine, no standalone routes needed.
\ No newline at end of file
<?php
use Engine\Core\Router;
Router::group('/saved-filters', ['middleware' => ['auth', 'csrf']], function () {
Router::get('', [\Modules\SavedFilters\Controllers\SavedFilterController::class, 'index']);
Router::post('', [\Modules\SavedFilters\Controllers\SavedFilterController::class, 'create']);
Router::delete('/{filterId}', [\Modules\SavedFilters\Controllers\SavedFilterController::class, 'delete']);
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/saved-filters', [\Modules\SavedFilters\Controllers\SavedFilterController::class, 'index']);
Router::post('/saved-filters', [\Modules\SavedFilters\Controllers\SavedFilterController::class, 'create']);
Router::delete('/saved-filters/{filterId}', [\Modules\SavedFilters\Controllers\SavedFilterController::class, 'delete']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
use Modules\Schedules\Controllers\ScheduleController;
return function (Router $router) {
$router->get('/schedules/user/{userId}', [ScheduleController::class, 'currentSchedule']);
$router->get('/schedules/requests', [ScheduleController::class, 'requests']);
$router->post('/schedules/requests', [ScheduleController::class, 'submitRequest']);
$router->post('/schedules/requests/{requestId}/review', [ScheduleController::class, 'reviewRequest']);
$router->put('/schedules/user/{userId}/direct', [ScheduleController::class, 'directEdit']);
$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
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/schedules/{userId}', [\Modules\Schedules\Controllers\ScheduleController::class, 'currentSchedule']);
Router::get('/schedules/requests', [\Modules\Schedules\Controllers\ScheduleController::class, 'requests']);
Router::post('/schedules/requests', [\Modules\Schedules\Controllers\ScheduleController::class, 'submitRequest']);
Router::post('/schedules/requests/{requestId}/review', [\Modules\Schedules\Controllers\ScheduleController::class, 'reviewRequest']);
Router::post('/schedules/{userId}/direct', [\Modules\Schedules\Controllers\ScheduleController::class, 'directEdit']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->get('/api/search', Modules\Search\Controllers\SearchController::class, 'search')
->middleware([Middleware\AuthenticationMiddleware::class]);
\ No newline at end of file
use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/search', [\Modules\Search\Controllers\SearchController::class, 'search']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
Router::group('/session-management', ['middleware' => ['auth', 'audit']], function () {
Router::get('/sessions', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'allSessions']);
Router::delete('/sessions/{sessionId}', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'killSession']);
Router::delete('/users/{userId}/sessions', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'killAllForUser']);
Router::delete('/sessions-all', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'killAll']);
Router::get('/login-history', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'loginHistory']);
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/session-management', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'allSessions']);
Router::delete('/session-management/{sessionId}', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'killSession']);
Router::delete('/session-management/user/{userId}', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'killAllForUser']);
Router::post('/session-management/kill-all', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'killAll']);
Router::get('/session-management/login-history', [\Modules\SessionManagement\Controllers\SessionManagementController::class, 'loginHistory']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/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');
use Engine\Core\Router;
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/settings', [\Modules\Settings\Controllers\SettingsController::class, 'index']);
Router::put('/settings', [\Modules\Settings\Controllers\SettingsController::class, 'update']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
Router::group('/system-health', ['middleware' => ['auth', 'audit']], function () {
Router::get('/', [\Modules\SystemHealth\Controllers\SystemHealthController::class, 'index']);
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/system-health', [\Modules\SystemHealth\Controllers\SystemHealthController::class, 'index']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
use Modules\TeamAvailability\Controllers\TeamAvailabilityController;
return function (Router $router) {
$router->get('/team-availability', [TeamAvailabilityController::class, 'index']);
$router->get('/api/team-availability', [TeamAvailabilityController::class, 'index']);
};
\ No newline at end of file
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/team-availability', [\Modules\TeamAvailability\Controllers\TeamAvailabilityController::class, 'index']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
use Modules\Unavailability\Controllers\UnavailabilityController;
return function (Router $router) {
$router->get('/unavailability', [UnavailabilityController::class, 'index']);
$router->post('/unavailability', [UnavailabilityController::class, 'create']);
$router->put('/unavailability/{id}', [UnavailabilityController::class, 'update']);
$router->delete('/unavailability/{id}', [UnavailabilityController::class, 'delete']);
$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
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/unavailability', [\Modules\Unavailability\Controllers\UnavailabilityController::class, 'index']);
Router::post('/unavailability', [\Modules\Unavailability\Controllers\UnavailabilityController::class, 'create']);
Router::put('/unavailability/{id}', [\Modules\Unavailability\Controllers\UnavailabilityController::class, 'update']);
Router::delete('/unavailability/{id}', [\Modules\Unavailability\Controllers\UnavailabilityController::class, 'delete']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/users',
'middleware' => [
Middleware\AuthenticationMiddleware::class,
Middleware\CSRFMiddleware::class,
Middleware\BlockingNotificationMiddleware::class,
]
], 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');
use Engine\Core\Router;
Router::group('', ['middleware' => ['auth', 'blocking_notif']], function () {
Router::get('/users', [\Modules\Users\Controllers\UserController::class, 'directory']);
Router::get('/users/{userId}', [\Modules\Users\Controllers\UserController::class, 'show']);
Router::put('/users/{userId}', [\Modules\Users\Controllers\UserController::class, 'update']);
Router::post('/users/{userId}/salary', [\Modules\Users\Controllers\UserController::class, 'setSalary']);
Router::post('/users/{userId}/status', [\Modules\Users\Controllers\UserController::class, 'changeStatus']);
Router::get('/users/{userId}/notes', [\Modules\Users\Controllers\UserController::class, 'privateNotes']);
Router::post('/users/{userId}/notes', [\Modules\Users\Controllers\UserController::class, 'addPrivateNote']);
Router::get('/users/{userId}/sessions', [\Modules\Users\Controllers\UserController::class, 'sessions']);
Router::post('/users/{userId}/force-logout', [\Modules\Users\Controllers\UserController::class, 'forceLogout']);
Router::get('/users/{userId}/salary-history', [\Modules\Users\Controllers\UserController::class, 'salaryHistory']);
});
\ No newline at end of file
<?php
use Engine\Core\Router;
Router::group('/webhooks', ['middleware' => ['auth', 'audit']], function () {
Router::get('/', [\Modules\Webhooks\Controllers\WebhookController::class, 'index']);
Router::post('/', [\Modules\Webhooks\Controllers\WebhookController::class, 'create']);
Router::put('/{webhookId}', [\Modules\Webhooks\Controllers\WebhookController::class, 'update']);
Router::post('/{webhookId}/test', [\Modules\Webhooks\Controllers\WebhookController::class, 'test']);
Router::get('/{webhookId}/deliveries', [\Modules\Webhooks\Controllers\WebhookController::class, 'deliveries']);
Router::delete('/{webhookId}', [\Modules\Webhooks\Controllers\WebhookController::class, 'delete']);
Router::group('', ['middleware' => ['auth']], function () {
Router::get('/webhooks', [\Modules\Webhooks\Controllers\WebhookController::class, 'index']);
Router::post('/webhooks', [\Modules\Webhooks\Controllers\WebhookController::class, 'create']);
Router::put('/webhooks/{webhookId}', [\Modules\Webhooks\Controllers\WebhookController::class, 'update']);
Router::post('/webhooks/{webhookId}/test', [\Modules\Webhooks\Controllers\WebhookController::class, 'test']);
Router::get('/webhooks/{webhookId}/deliveries', [\Modules\Webhooks\Controllers\WebhookController::class, 'deliveries']);
Router::delete('/webhooks/{webhookId}', [\Modules\Webhooks\Controllers\WebhookController::class, 'delete']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
// FORCE error display — remove after debugging
error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('log_errors', '1');
// ─── BOOTSTRAP ───
$container = require __DIR__ . '/../bootstrap/app.php';
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 {
require ROOT_PATH . '/bootstrap/app.php';
$app = \Engine\Core\Container::getInstance()->resolve(\Engine\Core\App::class);
$app->run();
$response = $router->dispatch($request);
} catch (\Engine\Auth\ForbiddenException $e) {
$response = $request->wantsJson()
? Response::json(['error' => 'Forbidden'], 403)
: Response::html('<h1>403 Forbidden</h1>', 403);
} catch (\Throwable $e) {
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
echo '<!DOCTYPE html><html><head><title>500 Error</title>';
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>';
echo '</head><body>';
echo '<h1>500 — Bootstrap Failed</h1>';
echo '<pre>';
echo htmlspecialchars($e->getMessage()) . "\n\n";
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
error_log("REQUEST ERROR: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}\n{$e->getTraceAsString()}");
$response = $request->wantsJson()
? Response::json(['error' => 'Internal server error'], 500)
: Response::html(file_get_contents(ROOT_PATH . '/templates/errors/500.php') ?: '<h1>500</h1>', 500);
}
$response->send();
\ 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)): ?>
<div class="alert alert-error"><?= $__engine->e($error) ?></div>
<?php endif; ?>
<form method="POST" action="/login" class="auth-form">
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>">
<h2>Sign In</h2>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus autocomplete="username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary btn-block">Sign In</button>
</form>
\ No newline at end of file
<form method="POST" action="/login">
<div class="form-group">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-control" required autofocus autocomplete="username">
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary btn-block btn-lg" style="margin-top:8px;">Login</button>
</form>
</div>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment