Commit e92d64bc authored by Administrator's avatar Administrator

Update 38 files via Son of Anton

parent 7d22a012
<?php
declare(strict_types=1);
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;
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,
];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Middleware;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
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;
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);
}
$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']]);
// 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);
}
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);
}
}
$request->setUser($user);
$request->setAttribute('api_key_id', $keyRecord['id']);
$request->setAttribute('api_key_scope', $scope);
$request->setAttribute('is_api_request', true);
return $next($request);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Middleware;
use Engine\Core\Request;
use Engine\Core\Response;
final class 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);
return $response;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\API\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Auth\Authenticator;
use Engine\Auth\SessionManager;
final class ApiAuthController
{
private Authenticator $auth;
private SessionManager $sessions;
public function __construct()
{
$c = Container::getInstance();
$this->auth = $c->resolve(Authenticator::class);
$this->sessions = $c->resolve(SessionManager::class);
}
public function login(Request $request): Response
{
$username = $request->input('username', '');
$password = $request->input('password', '');
if (!$username || !$password) {
return Response::json(['error' => 'Username and password required.'], 422);
}
$result = $this->auth->attempt($username, $password, $request->ip(), $request->userAgent());
if (!$result['success']) {
return Response::json(['error' => $result['error']], 401);
}
return Response::json([
'success' => true,
'user_id' => $result['user_id'],
'role' => $result['role'],
'force_password_change' => $result['force_password_change'],
'session_token' => session_id(),
]);
}
public function logout(Request $request): Response
{
$this->sessions->destroy();
return Response::json(['success' => true]);
}
public function me(Request $request): Response
{
$user = $request->user();
if (!$user) return Response::json(['error' => 'Not authenticated'], 401);
$safe = [
'id' => $user['id'], 'username' => $user['username'], 'role' => $user['role'],
'full_name_en' => $user['full_name_en'], 'status' => $user['status'],
'contractor_type' => $user['contractor_type'],
];
return Response::json(['user' => $safe]);
}
}
\ No newline at end of file
This diff is collapsed.
<?php
use Engine\Core\Router;
// Session-based API (for frontend)
Router::group('/api', ['middleware' => ['json_body']], function () {
Router::post('/auth/login', [\Modules\API\Controllers\ApiAuthController::class, 'login']);
Router::group('', ['middleware' => ['auth']], function () {
Router::post('/auth/logout', [\Modules\API\Controllers\ApiAuthController::class, 'logout']);
Router::get('/auth/me', [\Modules\API\Controllers\ApiAuthController::class, 'me']);
Router::get('/users', [\Modules\API\Controllers\ApiResourceController::class, 'listUsers']);
Router::get('/users/{id}', [\Modules\API\Controllers\ApiResourceController::class, 'getUser']);
Router::get('/users/{id}/tasks', [\Modules\API\Controllers\ApiResourceController::class, 'getUserTasks']);
Router::get('/users/{id}/reports', [\Modules\API\Controllers\ApiResourceController::class, 'getUserReports']);
Router::get('/users/{id}/deductions', [\Modules\API\Controllers\ApiResourceController::class, 'getUserDeductions']);
Router::get('/users/{id}/evaluations', [\Modules\API\Controllers\ApiResourceController::class, 'getUserEvaluations']);
Router::get('/users/{id}/salary', [\Modules\API\Controllers\ApiResourceController::class, 'getUserSalary']);
Router::get('/boards', [\Modules\API\Controllers\ApiResourceController::class, 'listBoards']);
Router::get('/boards/{id}', [\Modules\API\Controllers\ApiResourceController::class, 'getBoard']);
Router::get('/boards/{boardId}/cards', [\Modules\API\Controllers\ApiResourceController::class, 'listCards']);
Router::get('/cards/{id}', [\Modules\API\Controllers\ApiResourceController::class, 'getCard']);
Router::get('/search', [\Modules\API\Controllers\ApiResourceController::class, 'search']);
Router::get('/notifications', [\Modules\API\Controllers\ApiResourceController::class, 'listNotifications']);
Router::put('/notifications/{id}/read', [\Modules\API\Controllers\ApiResourceController::class, 'markNotificationRead']);
Router::put('/notifications/{id}/acknowledge', [\Modules\API\Controllers\ApiResourceController::class, 'acknowledgeNotification']);
Router::put('/notifications/read-all', [\Modules\API\Controllers\ApiResourceController::class, 'markAllRead']);
Router::get('/settings', [\Modules\API\Controllers\ApiResourceController::class, 'getSettings']);
Router::put('/settings', [\Modules\API\Controllers\ApiResourceController::class, 'updateSettings']);
});
});
// API-key authenticated endpoints (for external integrations)
Router::group('/api/v1', ['middleware' => ['api_key_auth', 'json_body']], function () {
Router::get('/users', [\Modules\API\Controllers\ApiResourceController::class, 'listUsers']);
Router::get('/users/{id}', [\Modules\API\Controllers\ApiResourceController::class, 'getUser']);
Router::get('/users/{id}/tasks', [\Modules\API\Controllers\ApiResourceController::class, 'getUserTasks']);
Router::get('/users/{id}/salary', [\Modules\API\Controllers\ApiResourceController::class, 'getUserSalary']);
Router::get('/boards', [\Modules\API\Controllers\ApiResourceController::class, 'listBoards']);
Router::get('/boards/{id}', [\Modules\API\Controllers\ApiResourceController::class, 'getBoard']);
Router::get('/boards/{boardId}/cards', [\Modules\API\Controllers\ApiResourceController::class, 'listCards']);
Router::get('/cards/{id}', [\Modules\API\Controllers\ApiResourceController::class, 'getCard']);
Router::get('/search', [\Modules\API\Controllers\ApiResourceController::class, 'search']);
});
\ No newline at end of file
This diff is collapsed.
<?php
declare(strict_types=1);
namespace Modules\Analytics\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Template\TemplateEngine;
final class ReportBuilderController
{
private Connection $db;
private PermissionEngine $perms;
private TemplateEngine $templates;
private static array $dataSources = [
'contractors' => [
'table' => 'users',
'base_where' => "role = 'contractor'",
'columns' => [
'id' => 'integer', 'full_name_en' => 'string', 'contractor_type' => 'string',
'status' => 'string', 'actual_salary' => 'decimal', 'base_salary' => 'decimal',
'activation_date' => 'date', 'created_at' => 'datetime',
],
'aggregatable' => ['actual_salary', 'base_salary'],
],
'deductions' => [
'table' => 'deductions',
'base_where' => "deleted_at IS NULL AND status IN ('applied','applied_no_response','reduced','accepted')",
'columns' => [
'id' => 'integer', 'contractor_id' => 'integer', 'category' => 'string',
'sub_category' => 'string', 'calculated_amount' => 'decimal', 'final_amount' => 'decimal',
'status' => 'string', 'payroll_month' => 'string', 'violation_date' => 'date', 'created_at' => 'datetime',
],
'aggregatable' => ['calculated_amount', 'final_amount'],
],
'bounties' => [
'table' => 'bounty_payouts',
'base_where' => '1=1',
'columns' => [
'id' => 'integer', 'card_id' => 'integer', 'recipient_id' => 'integer',
'amount' => 'decimal', 'total_bounty' => 'decimal', 'split_percentage' => 'decimal',
'payroll_month' => 'string', 'created_at' => 'datetime',
],
'aggregatable' => ['amount', 'total_bounty'],
],
'reports' => [
'table' => 'daily_reports',
'base_where' => '1=1',
'columns' => [
'id' => 'integer', 'user_id' => 'integer', 'report_date' => 'date',
'status' => 'string', 'total_hours' => 'decimal', 'is_on_time' => 'boolean',
'submitted_at' => 'datetime',
],
'aggregatable' => ['total_hours'],
],
'cards' => [
'table' => 'cards',
'base_where' => '1=1',
'columns' => [
'id' => 'integer', 'board_id' => 'integer', 'card_key' => 'string',
'title' => 'string', 'priority' => 'string', 'bounty_amount' => 'decimal',
'estimated_hours' => 'decimal', 'is_archived' => 'boolean', 'done_at' => 'datetime',
'created_at' => 'datetime',
],
'aggregatable' => ['bounty_amount', 'estimated_hours'],
],
'payroll' => [
'table' => 'payroll_records',
'base_where' => '1=1',
'columns' => [
'id' => 'integer', 'contractor_id' => 'integer', 'month' => 'string',
'actual_salary' => 'decimal', 'total_bounties' => 'decimal',
'net_payable' => 'decimal', 'status' => 'string', 'paid_at' => 'datetime',
],
'aggregatable' => ['actual_salary', 'total_bounties', 'net_payable'],
],
'evaluations' => [
'table' => 'compiled_evaluations',
'base_where' => '1=1',
'columns' => [
'id' => 'integer', 'contractor_id' => 'integer', 'technical_score' => 'decimal',
'professional_score' => 'decimal', 'overall_score' => 'decimal', 'rating' => 'string',
'compiled_at' => 'datetime',
],
'aggregatable' => ['technical_score', 'professional_score', 'overall_score'],
],
];
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function index(Request $request): Response
{
$user = $request->user();
if (!in_array($user['role'], ['super_admin', 'admin', 'project_leader'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
return Response::html($this->templates->render('analytics/report_builder', [
'user' => $user,
'data_sources' => array_map(fn($ds) => ['columns' => $ds['columns'], 'aggregatable' => $ds['aggregatable']], self::$dataSources),
]));
}
public function sources(Request $request): Response
{
return Response::json(['sources' => array_map(fn($ds) => [
'columns' => $ds['columns'], 'aggregatable' => $ds['aggregatable'],
], self::$dataSources)]);
}
public function execute(Request $request): Response
{
$user = $request->user();
if (!in_array($user['role'], ['super_admin', 'admin', 'project_leader'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$source = $request->input('source');
$config = self::$dataSources[$source] ?? null;
if (!$config) return Response::json(['error' => 'Invalid data source'], 422);
$selectCols = $request->input('columns', array_keys($config['columns']));
$filters = $request->input('filters', []);
$groupBy = $request->input('group_by');
$aggregation = $request->input('aggregation');
$aggColumn = $request->input('agg_column');
$sortBy = $request->input('sort_by', 'id');
$sortDir = strtoupper($request->input('sort_dir', 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$limit = min(5000, max(1, (int)($request->input('limit', 1000))));
// Validate columns
$validCols = array_keys($config['columns']);
$selectCols = array_intersect($selectCols, $validCols);
if (empty($selectCols)) $selectCols = $validCols;
$where = [$config['base_where']];
$params = [];
foreach ($filters as $filter) {
$col = $filter['column'] ?? '';
$op = $filter['operator'] ?? '=';
$val = $filter['value'] ?? '';
if (!in_array($col, $validCols)) continue;
if (!in_array($op, ['=', '!=', '>', '<', '>=', '<=', 'LIKE', 'NOT LIKE', 'IS NULL', 'IS NOT NULL'])) continue;
if (in_array($op, ['IS NULL', 'IS NOT NULL'])) {
$where[] = "`{$col}` {$op}";
} else {
$where[] = "`{$col}` {$op} ?";
$params[] = $op === 'LIKE' || $op === 'NOT LIKE' ? "%{$val}%" : $val;
}
}
$whereClause = implode(' AND ', $where);
if ($groupBy && in_array($groupBy, $validCols) && $aggregation && $aggColumn && in_array($aggColumn, $config['aggregatable'])) {
$aggFunc = match($aggregation) {
'sum' => 'SUM', 'avg' => 'AVG', 'count' => 'COUNT', 'min' => 'MIN', 'max' => 'MAX',
default => 'SUM',
};
$sql = "SELECT `{$groupBy}`, {$aggFunc}(`{$aggColumn}`) as agg_value, COUNT(*) as row_count
FROM `{$config['table']}` WHERE {$whereClause}
GROUP BY `{$groupBy}` ORDER BY agg_value {$sortDir} LIMIT {$limit}";
} else {
$colStr = implode(', ', array_map(fn($c) => "`{$c}`", $selectCols));
if (!in_array($sortBy, $validCols)) $sortBy = 'id';
$sql = "SELECT {$colStr} FROM `{$config['table']}` WHERE {$whereClause}
ORDER BY `{$sortBy}` {$sortDir} LIMIT {$limit}";
}
$rows = $this->db->fetchAll($sql, $params);
return Response::json([
'rows' => $rows,
'count' => count($rows),
'source' => $source,
'sql_preview' => preg_replace('/\s+/', ' ', $sql),
]);
}
}
\ 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']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\ApiKeys\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
final class ApiKeyController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
}
public function index(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'api_keys.manage');
$keys = $this->db->fetchAll(
"SELECT ak.id, ak.name, ak.key_prefix, ak.scope, ak.rate_limit_per_hour,
ak.last_used_at, ak.revoked_at, ak.created_at,
u.full_name_en as created_by_name
FROM api_keys ak
JOIN users u ON u.id = ak.created_by_id
ORDER BY ak.created_at DESC"
);
if ($request->wantsJson()) {
return Response::json(['api_keys' => $keys]);
}
$templates = Container::getInstance()->resolve(\Engine\Template\TemplateEngine::class);
return Response::html($templates->render('api_keys/index', ['user' => $user, 'api_keys' => $keys]));
}
public function create(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'api_keys.manage');
$name = $request->input('name');
$scope = $request->input('scope', 'read_only');
if (!$name || strlen($name) < 3) {
return Response::json(['error' => 'Name is required (min 3 chars).'], 422);
}
if (!in_array($scope, ['read_only', 'read_write', 'admin'])) {
return Response::json(['error' => 'Invalid scope.'], 422);
}
$rawKey = bin2hex(random_bytes(32));
$prefix = substr($rawKey, 0, 8);
$hash = password_hash($rawKey, PASSWORD_BCRYPT, ['cost' => 12]);
$id = $this->db->insert('api_keys', [
'name' => $name,
'key_hash' => $hash,
'key_prefix' => $prefix,
'scope' => $scope,
'rate_limit_per_hour' => (int)($request->input('rate_limit_per_hour', 1000)),
'created_by_id' => $user['id'],
]);
$this->audit->log($user, 'API_KEY_CREATED', 'api_key', $id, 'api_keys', '/api-keys',
null, ['name' => $name, 'scope' => $scope], $request->ip(), $request->userAgent());
return Response::json([
'success' => true,
'id' => $id,
'api_key' => $rawKey,
'prefix' => $prefix,
'warning' => 'This key is shown ONCE. Copy it now. It cannot be retrieved later.',
]);
}
public function revoke(Request $request, string $keyId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'api_keys.manage');
$key = $this->db->fetchOne("SELECT * FROM api_keys WHERE id = ?", [(int)$keyId]);
if (!$key) return Response::json(['error' => 'Not found'], 404);
if ($key['revoked_at']) return Response::json(['error' => 'Already revoked'], 422);
$this->db->update('api_keys', ['revoked_at' => date('Y-m-d H:i:s')], 'id = ?', [(int)$keyId]);
$this->audit->log($user, 'API_KEY_REVOKED', 'api_key', (int)$keyId, 'api_keys', '/api-keys',
null, null, $request->ip(), $request->userAgent());
return Response::json(['success' => true]);
}
public function delete(Request $request, string $keyId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'api_keys.manage');
$this->db->delete('api_keys', 'id = ?', [(int)$keyId]);
$this->audit->log($user, 'API_KEY_DELETED', 'api_key', (int)$keyId, 'api_keys', '/api-keys',
null, null, $request->ip(), $request->userAgent());
return Response::json(['success' => true]);
}
}
\ 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']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\AuditTrail\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Template\TemplateEngine;
final class AuditTrailController
{
private Connection $db;
private PermissionEngine $perms;
private TemplateEngine $templates;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function index(Request $request): Response
{
$user = $request->user();
if ($user['role'] === 'super_admin') {
$this->perms->denyUnlessAllowed($user, 'audit_trail.view.full');
} else {
$this->perms->denyUnlessAllowed($user, 'audit_trail.view.limited');
}
$page = max(1, (int)($request->query('page', 1)));
$perPage = 100;
$offset = ($page - 1) * $perPage;
$where = [];
$params = [];
if ($user['role'] !== 'super_admin') {
$where[] = "user_id = ?";
$params[] = $user['id'];
}
$userId = $request->query('user_id');
if ($userId && $user['role'] === 'super_admin') {
$where[] = "user_id = ?";
$params[] = (int)$userId;
}
$action = $request->query('action');
if ($action) { $where[] = "action = ?"; $params[] = $action; }
$entityType = $request->query('entity_type');
if ($entityType) { $where[] = "entity_type = ?"; $params[] = $entityType; }
$entityId = $request->query('entity_id');
if ($entityId) { $where[] = "entity_id = ?"; $params[] = (int)$entityId; }
$module = $request->query('module');
if ($module) { $where[] = "module = ?"; $params[] = $module; }
$dateFrom = $request->query('date_from');
if ($dateFrom) { $where[] = "created_at >= ?"; $params[] = $dateFrom . ' 00:00:00'; }
$dateTo = $request->query('date_to');
if ($dateTo) { $where[] = "created_at <= ?"; $params[] = $dateTo . ' 23:59:59'; }
$search = trim($request->query('search', ''));
if ($search) {
$where[] = "(username LIKE ? OR action LIKE ? OR entity_type LIKE ? OR endpoint LIKE ?)";
$params[] = "%{$search}%"; $params[] = "%{$search}%"; $params[] = "%{$search}%"; $params[] = "%{$search}%";
}
$whereClause = !empty($where) ? ' WHERE ' . implode(' AND ', $where) : '';
$total = (int)$this->db->fetchColumn("SELECT COUNT(*) FROM audit_trail{$whereClause}", $params);
$entries = $this->db->fetchAll(
"SELECT id, user_id, username, user_role, action, entity_type, entity_id, module, endpoint, ip_address, created_at
FROM audit_trail{$whereClause} ORDER BY created_at DESC LIMIT {$perPage} OFFSET {$offset}",
$params
);
$actions = $this->db->fetchAll("SELECT DISTINCT action FROM audit_trail ORDER BY action");
$entityTypes = $this->db->fetchAll("SELECT DISTINCT entity_type FROM audit_trail ORDER BY entity_type");
$modules = $this->db->fetchAll("SELECT DISTINCT module FROM audit_trail ORDER BY module");
$data = [
'user' => $user,
'entries' => $entries,
'total' => $total,
'page' => $page,
'last_page' => max(1, (int)ceil($total / $perPage)),
'actions' => array_column($actions, 'action'),
'entity_types' => array_column($entityTypes, 'entity_type'),
'modules' => array_column($modules, 'module'),
];
if ($request->wantsJson()) return Response::json($data);
return Response::html($this->templates->render('audit_trail/index', $data));
}
public function show(Request $request, string $entryId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'audit_trail.view.full');
$entry = $this->db->fetchOne("SELECT * FROM audit_trail WHERE id = ?", [(int)$entryId]);
if (!$entry) return Response::json(['error' => 'Not found'], 404);
return Response::json(['entry' => $entry]);
}
public function stats(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'audit_trail.view.full');
$today = date('Y-m-d');
$weekAgo = date('Y-m-d', strtotime('-7 days'));
$stats = [
'total_entries' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM audit_trail"),
'today_entries' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM audit_trail WHERE DATE(created_at) = ?", [$today]),
'week_entries' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM audit_trail WHERE created_at >= ?", [$weekAgo . ' 00:00:00']),
'top_actions_today' => $this->db->fetchAll(
"SELECT action, COUNT(*) as cnt FROM audit_trail WHERE DATE(created_at) = ? GROUP BY action ORDER BY cnt DESC LIMIT 10",
[$today]
),
'top_users_today' => $this->db->fetchAll(
"SELECT username, COUNT(*) as cnt FROM audit_trail WHERE DATE(created_at) = ? AND username IS NOT NULL GROUP BY username ORDER BY cnt DESC LIMIT 10",
[$today]
),
'oldest_entry' => $this->db->fetchColumn("SELECT MIN(created_at) FROM audit_trail"),
'newest_entry' => $this->db->fetchColumn("SELECT MAX(created_at) FROM audit_trail"),
];
return Response::json($stats);
}
}
\ 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']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\ControlPanel\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Template\TemplateEngine;
final class ControlPanelController
{
private Connection $db;
private PermissionEngine $perms;
private TemplateEngine $templates;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function index(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'control_panel.access');
$counts = [
'users' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM users"),
'active_users' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM users WHERE status = 'active'"),
'boards' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM boards WHERE is_archived = 0"),
'cards' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM cards WHERE is_archived = 0"),
'deductions' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM deductions WHERE deleted_at IS NULL"),
'reports' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM daily_reports"),
'payroll' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM payroll_records"),
'evaluations' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM compiled_evaluations"),
'pips' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM pips WHERE deleted_at IS NULL"),
'messages' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM messages WHERE deleted_at IS NULL"),
'meetings' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM meetings"),
'holidays' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM holidays"),
'notices' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM notices"),
'policies' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM policies"),
'contracts' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM contracts"),
'webhooks' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM webhooks"),
'api_keys' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM api_keys WHERE revoked_at IS NULL"),
'audit_entries' => (int)$this->db->fetchColumn("SELECT COUNT(*) FROM audit_trail"),
];
return Response::html($this->templates->render('control_panel/index', [
'user' => $user,
'counts' => $counts,
]));
}
}
\ No newline at end of file
This diff is collapsed.
<?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']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Export\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
final class ExportController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
}
public function exportCsv(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'data.export');
$entity = $request->input('entity');
$filters = $request->input('filters', []);
$columns = $request->input('columns', []);
$entityMap = [
'users' => ['table' => 'users', 'allowed' => ['super_admin', 'admin']],
'payroll' => ['table' => 'payroll_records', 'allowed' => ['super_admin', 'admin']],
'deductions' => ['table' => 'deductions', 'allowed' => ['super_admin', 'admin']],
'evaluations' => ['table' => 'compiled_evaluations', 'allowed' => ['super_admin', 'admin']],
'reports' => ['table' => 'daily_reports', 'allowed' => ['super_admin', 'admin', 'project_leader']],
'cards' => ['table' => 'cards', 'allowed' => ['super_admin', 'admin', 'project_leader']],
'bounties' => ['table' => 'bounty_payouts', 'allowed' => ['super_admin', 'admin']],
'adjustments' => ['table' => 'manual_adjustments', 'allowed' => ['super_admin', 'admin']],
];
$config = $entityMap[$entity] ?? null;
if (!$config) return Response::json(['error' => 'Invalid entity for export'], 422);
if (!in_array($user['role'], $config['allowed'])) return Response::json(['error' => 'Forbidden'], 403);
$where = [];
$params = [];
if (!empty($filters['month'])) {
$monthCol = match($entity) {
'payroll' => 'month',
'deductions' => 'payroll_month',
'bounties' => 'payroll_month',
'adjustments' => 'effective_month',
default => null,
};
if ($monthCol) {
$where[] = "`{$monthCol}` = ?";
$params[] = $filters['month'];
}
}
if (!empty($filters['date_from'])) {
$where[] = "created_at >= ?";
$params[] = $filters['date_from'] . ' 00:00:00';
}
if (!empty($filters['date_to'])) {
$where[] = "created_at <= ?";
$params[] = $filters['date_to'] . ' 23:59:59';
}
if (!empty($filters['status'])) {
$where[] = "status = ?";
$params[] = $filters['status'];
}
$whereClause = !empty($where) ? ' WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT * FROM `{$config['table']}`{$whereClause} ORDER BY id DESC LIMIT 50000";
$rows = $this->db->fetchAll($sql, $params);
if (empty($rows)) {
return Response::json(['error' => 'No data to export'], 404);
}
// Filter columns if specified
if (!empty($columns)) {
$rows = array_map(function ($row) use ($columns) {
return array_intersect_key($row, array_flip($columns));
}, $rows);
}
// Strip sensitive fields
$sensitiveFields = ['password_hash', 'temp_password_hash', 'temp_password_expires_at'];
$rows = array_map(function ($row) use ($sensitiveFields) {
foreach ($sensitiveFields as $f) unset($row[$f]);
return $row;
}, $rows);
$this->audit->log($user, 'DATA_EXPORTED', $entity, null, 'export',
'/export/csv', null, ['entity' => $entity, 'count' => count($rows)],
$request->ip(), $request->userAgent());
$filename = "{$entity}_export_" . date('Y-m-d_His') . '.csv';
$headers = array_keys($rows[0]);
ob_start();
$output = fopen('php://output', 'w');
fputcsv($output, $headers);
foreach ($rows as $row) {
fputcsv($output, array_values($row));
}
fclose($output);
$csv = ob_get_clean();
return Response::make($csv, 200, [
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
'Content-Length' => strlen($csv),
]);
}
public function exportContractorZip(Request $request, string $userId): Response
{
$user = $request->user();
if ($user['role'] !== 'super_admin') return Response::json(['error' => 'Forbidden'], 403);
$contractorId = (int)$userId;
$contractor = $this->db->fetchOne("SELECT * FROM users WHERE id = ?", [$contractorId]);
if (!$contractor) return Response::json(['error' => 'User not found'], 404);
unset($contractor['password_hash'], $contractor['temp_password_hash'], $contractor['temp_password_expires_at']);
$package = [
'profile' => $contractor,
'salary_history' => $this->db->fetchAll("SELECT * FROM salary_history WHERE user_id = ?", [$contractorId]),
'schedule' => $this->db->fetchAll("SELECT * FROM user_schedule_days WHERE user_id = ?", [$contractorId]),
'reports' => $this->db->fetchAll("SELECT * FROM daily_reports WHERE user_id = ? ORDER BY report_date DESC", [$contractorId]),
'deductions' => $this->db->fetchAll("SELECT * FROM deductions WHERE contractor_id = ? AND deleted_at IS NULL ORDER BY created_at DESC", [$contractorId]),
'bounties' => $this->db->fetchAll("SELECT * FROM bounty_payouts WHERE recipient_id = ? ORDER BY created_at DESC", [$contractorId]),
'adjustments' => $this->db->fetchAll("SELECT * FROM manual_adjustments WHERE contractor_id = ? AND deleted_at IS NULL ORDER BY created_at DESC", [$contractorId]),
'payroll' => $this->db->fetchAll("SELECT * FROM payroll_records WHERE contractor_id = ? ORDER BY month DESC", [$contractorId]),
'evaluations' => $this->db->fetchAll("SELECT * FROM compiled_evaluations WHERE contractor_id = ? ORDER BY compiled_at DESC", [$contractorId]),
'pips' => $this->db->fetchAll("SELECT * FROM pips WHERE contractor_id = ? AND deleted_at IS NULL", [$contractorId]),
'learning_goals' => $this->db->fetchAll("SELECT * FROM learning_goals WHERE contractor_id = ? AND deleted_at IS NULL", [$contractorId]),
'contracts' => $this->db->fetchAll("SELECT * FROM contracts WHERE contractor_id = ?", [$contractorId]),
'status_history' => $this->db->fetchAll("SELECT * FROM contractor_status_history WHERE user_id = ?", [$contractorId]),
'unavailability' => $this->db->fetchAll("SELECT * FROM unavailability_records WHERE user_id = ?", [$contractorId]),
];
$this->audit->log($user, 'CONTRACTOR_DATA_EXPORTED', 'user', $contractorId, 'export',
"/export/contractor/{$userId}", null, null, $request->ip(), $request->userAgent());
$json = json_encode($package, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
$filename = "contractor_{$contractor['username']}_data_" . date('Y-m-d') . '.json';
return Response::make($json, 200, [
'Content-Type' => 'application/json',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
'Content-Length' => strlen($json),
]);
}
public function exportAuditTrail(Request $request): Response
{
$user = $request->user();
if ($user['role'] !== 'super_admin') return Response::json(['error' => 'Forbidden'], 403);
$dateFrom = $request->input('date_from', date('Y-m-01'));
$dateTo = $request->input('date_to', date('Y-m-d'));
$format = $request->input('format', 'csv');
$rows = $this->db->fetchAll(
"SELECT id, user_id, username, user_role, action, entity_type, entity_id, module, endpoint, ip_address, created_at
FROM audit_trail WHERE created_at BETWEEN ? AND ? ORDER BY created_at DESC LIMIT 100000",
[$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59']
);
$this->audit->log($user, 'AUDIT_TRAIL_EXPORTED', 'audit_trail', null, 'export',
'/export/audit-trail', null, ['date_from' => $dateFrom, 'date_to' => $dateTo, 'count' => count($rows)],
$request->ip(), $request->userAgent());
if ($format === 'json') {
$json = json_encode($rows, JSON_PRETTY_PRINT);
return Response::make($json, 200, [
'Content-Type' => 'application/json',
'Content-Disposition' => 'attachment; filename="audit_trail_' . date('Y-m-d') . '.json"',
]);
}
ob_start();
$output = fopen('php://output', 'w');
if (!empty($rows)) {
fputcsv($output, array_keys($rows[0]));
foreach ($rows as $row) fputcsv($output, array_values($row));
}
fclose($output);
$csv = ob_get_clean();
return Response::make($csv, 200, [
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => 'attachment; filename="audit_trail_' . date('Y-m-d') . '.csv"',
]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Export\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
final class PdfExportController
{
private Connection $db;
private PermissionEngine $perms;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
}
public function payslip(Request $request, string $payrollId): Response
{
$user = $request->user();
$record = $this->db->fetchOne("SELECT * FROM payroll_records WHERE id = ?", [(int)$payrollId]);
if (!$record) return Response::json(['error' => 'Not found'], 404);
if ($user['role'] === 'contractor' && $record['contractor_id'] !== $user['id']) {
return Response::json(['error' => 'Forbidden'], 403);
}
if (!in_array($user['role'], ['super_admin', 'admin']) && $record['contractor_id'] !== $user['id']) {
return Response::json(['error' => 'Forbidden'], 403);
}
$contractor = $this->db->fetchOne("SELECT full_name_en, username, contractor_type, bank_name, bank_account_number FROM users WHERE id = ?", [$record['contractor_id']]);
$totalDeductions = $record['total_deductions_a'] + $record['total_deductions_b'] + $record['total_deductions_c'] + $record['total_deductions_d'];
$html = $this->generatePayslipHtml($record, $contractor, $totalDeductions);
return Response::make($html, 200, [
'Content-Type' => 'text/html; charset=utf-8',
'Content-Disposition' => "inline; filename=\"payslip_{$contractor['username']}_{$record['month']}.html\"",
]);
}
public function evaluationReport(Request $request, string $compiledId): Response
{
$user = $request->user();
$compiled = $this->db->fetchOne(
"SELECT ce.*, ec.month, u.full_name_en FROM compiled_evaluations ce
JOIN evaluation_cycles ec ON ec.id = ce.cycle_id
JOIN users u ON u.id = ce.contractor_id WHERE ce.id = ?",
[(int)$compiledId]
);
if (!$compiled) return Response::json(['error' => 'Not found'], 404);
if ($user['role'] === 'contractor' && $compiled['contractor_id'] !== $user['id']) {
return Response::json(['error' => 'Forbidden'], 403);
}
$techScores = $this->db->fetchAll(
"SELECT ecs.* FROM evaluation_criterion_scores ecs
JOIN evaluations e ON e.id = ecs.evaluation_id
WHERE e.cycle_id = ? AND e.contractor_id = ? AND e.type = 'technical'",
[$compiled['cycle_id'], $compiled['contractor_id']]
);
$profScores = $this->db->fetchAll(
"SELECT ecs.* FROM evaluation_criterion_scores ecs
JOIN evaluations e ON e.id = ecs.evaluation_id
WHERE e.cycle_id = ? AND e.contractor_id = ? AND e.type = 'professional'",
[$compiled['cycle_id'], $compiled['contractor_id']]
);
$html = $this->generateEvaluationHtml($compiled, $techScores, $profScores);
return Response::make($html, 200, [
'Content-Type' => 'text/html; charset=utf-8',
'Content-Disposition' => "inline; filename=\"evaluation_{$compiled['month']}.html\"",
]);
}
private function generatePayslipHtml(array $record, array $contractor, float $totalDeductions): string
{
$monthLabel = date('F Y', strtotime($record['month'] . '-01'));
$net = number_format($record['net_payable'], 2);
$actual = number_format($record['actual_salary'], 2);
$bounties = number_format($record['total_bounties'], 2);
$posAdj = number_format($record['total_positive_adjustments'], 2);
$negAdj = number_format($record['total_negative_adjustments'], 2);
$dedA = number_format($record['total_deductions_a'], 2);
$dedB = number_format($record['total_deductions_b'], 2);
$dedC = number_format($record['total_deductions_c'], 2);
$dedD = number_format($record['total_deductions_d'], 2);
$totalDed = number_format($totalDeductions, 2);
return <<<HTML
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Payslip — {$monthLabel}</title>
<style>
body{font-family:Arial,sans-serif;max-width:800px;margin:0 auto;padding:40px;color:#1a1a1a}
h1{text-align:center;border-bottom:3px solid #1a1a1a;padding-bottom:10px}
.meta{display:flex;justify-content:space-between;margin:20px 0}
table{width:100%;border-collapse:collapse;margin:20px 0}
th,td{padding:10px 12px;text-align:left;border-bottom:1px solid #ddd}
th{background:#f5f5f5;font-weight:bold}
.right{text-align:right}
.total{font-size:1.3em;font-weight:bold;border-top:3px solid #1a1a1a}
.positive{color:#16a34a}
.negative{color:#dc2626}
.footer{margin-top:40px;font-size:0.85em;color:#666;text-align:center;border-top:1px solid #ddd;padding-top:20px}
@media print{body{padding:20px}}
</style></head><body>
<h1>AL-ARCADE — Payslip</h1>
<div class="meta">
<div><strong>Contractor:</strong> {$contractor['full_name_en']}<br><strong>Username:</strong> {$contractor['username']}<br><strong>Type:</strong> {$contractor['contractor_type']}</div>
<div style="text-align:right"><strong>Period:</strong> {$monthLabel}<br><strong>Status:</strong> {$record['status']}<br><strong>Generated:</strong> {$record['created_at']}</div>
</div>
<table>
<tr><th>Item</th><th class="right">Amount (EGP)</th></tr>
<tr><td>Base Salary (Actual)</td><td class="right">{$actual}</td></tr>
<tr><td class="positive">+ Bounties Earned</td><td class="right positive">+{$bounties}</td></tr>
<tr><td class="positive">+ Positive Adjustments</td><td class="right positive">+{$posAdj}</td></tr>
<tr><td class="negative">− Category A Deductions (Deadline)</td><td class="right negative">-{$dedA}</td></tr>
<tr><td class="negative">− Category B Deductions (Reporting)</td><td class="right negative">-{$dedB}</td></tr>
<tr><td class="negative">− Category C Deductions (Quality)</td><td class="right negative">-{$dedC}</td></tr>
<tr><td class="negative">− Category D Deductions (Communication)</td><td class="right negative">-{$dedD}</td></tr>
<tr><td class="negative">− Negative Adjustments</td><td class="right negative">-{$negAdj}</td></tr>
<tr class="total"><td>NET PAYABLE</td><td class="right">{$net} EGP</td></tr>
</table>
<div><strong>Bank:</strong> {$contractor['bank_name']} — Account: {$contractor['bank_account_number']}</div>
<div class="footer">This is a system-generated document from AL-ARCADE HR Platform v3.0.<br>Generated on {$record['created_at']}. Document ID: PAY-{$record['id']}</div>
</body></html>
HTML;
}
private function generateEvaluationHtml(array $compiled, array $techScores, array $profScores): string
{
$monthLabel = date('F Y', strtotime($compiled['month'] . '-01'));
$ratingBadges = [
'exceptional' => '⭐ Exceptional', 'strong' => '🟢 Strong', 'adequate' => '🟡 Adequate',
'below_expectations' => '🟠 Below Expectations', 'unacceptable' => '🔴 Unacceptable',
];
$ratingLabel = $ratingBadges[$compiled['rating']] ?? $compiled['rating'];
$techRows = '';
foreach ($techScores as $s) {
$techRows .= "<tr><td>{$s['criterion_key']}</td><td class='right'>" . ($s['auto_value'] ?? '-') . "</td><td class='right'>" . ($s['manual_value'] ?? '-') . "</td><td class='right'><strong>{$s['final_value']}</strong></td><td class='right'>{$s['weight']}</td></tr>";
}
$profRows = '';
foreach ($profScores as $s) {
$profRows .= "<tr><td>{$s['criterion_key']}</td><td class='right'>" . ($s['auto_value'] ?? '-') . "</td><td class='right'>" . ($s['manual_value'] ?? '-') . "</td><td class='right'><strong>{$s['final_value']}</strong></td><td class='right'>{$s['weight']}</td></tr>";
}
return <<<HTML
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Evaluation — {$monthLabel}</title>
<style>
body{font-family:Arial,sans-serif;max-width:800px;margin:0 auto;padding:40px;color:#1a1a1a}
h1,h2{border-bottom:2px solid #1a1a1a;padding-bottom:8px}
table{width:100%;border-collapse:collapse;margin:15px 0}
th,td{padding:8px 10px;text-align:left;border-bottom:1px solid #ddd}
th{background:#f5f5f5}
.right{text-align:right}
.score-box{text-align:center;font-size:2em;padding:20px;margin:20px 0;background:#f0f0f0;border-radius:8px}
.footer{margin-top:40px;font-size:0.85em;color:#666;text-align:center}
</style></head><body>
<h1>Monthly Evaluation Report</h1>
<p><strong>Contractor:</strong> {$compiled['full_name_en']} | <strong>Period:</strong> {$monthLabel} | <strong>Compiled:</strong> {$compiled['compiled_at']}</p>
<div class="score-box">{$ratingLabel}<br><span style="font-size:0.5em">Overall Score: {$compiled['overall_score']} / 5.00</span></div>
<p><strong>Technical Score:</strong> {$compiled['technical_score']} | <strong>Professional Score:</strong> {$compiled['professional_score']}</p>
<h2>Technical Evaluation</h2>
<table><tr><th>Criterion</th><th class="right">Auto</th><th class="right">Manual</th><th class="right">Final</th><th class="right">Weight</th></tr>{$techRows}</table>
<h2>Professional Evaluation</h2>
<table><tr><th>Criterion</th><th class="right">Auto</th><th class="right">Manual</th><th class="right">Final</th><th class="right">Weight</th></tr>{$profRows}</table>
<div class="footer">AL-ARCADE HR Platform v3.0 — Evaluation ID: EVAL-{$compiled['id']}</div>
</body></html>
HTML;
}
}
\ 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']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\SessionManagement\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
final class SessionManagementController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
}
public function allSessions(Request $request): Response
{
$user = $request->user();
if ($user['role'] !== 'super_admin') return Response::json(['error' => 'Forbidden'], 403);
$sessions = $this->db->fetchAll(
"SELECT s.*, u.full_name_en, u.username, u.role
FROM sessions s JOIN users u ON u.id = s.user_id
ORDER BY s.last_activity_at DESC"
);
return Response::json(['sessions' => $sessions, 'total' => count($sessions)]);
}
public function killSession(Request $request, string $sessionId): Response
{
$user = $request->user();
if ($user['role'] !== 'super_admin') return Response::json(['error' => 'Forbidden'], 403);
$session = $this->db->fetchOne("SELECT * FROM sessions WHERE id = ?", [$sessionId]);
if (!$session) return Response::json(['error' => 'Session not found'], 404);
$this->db->delete('sessions', 'id = ?', [$sessionId]);
$this->audit->log($user, 'SESSION_KILLED', 'session', null, 'session_management',
'/session-management', null, ['target_user_id' => $session['user_id'], 'session_id' => $sessionId],
$request->ip(), $request->userAgent());
return Response::json(['success' => true]);
}
public function killAllForUser(Request $request, string $userId): Response
{
$user = $request->user();
if ($user['role'] !== 'super_admin') return Response::json(['error' => 'Forbidden'], 403);
$count = $this->db->delete('sessions', 'user_id = ?', [(int)$userId]);
$this->audit->log($user, 'ALL_SESSIONS_KILLED', 'user', (int)$userId, 'session_management',
'/session-management', null, ['sessions_killed' => $count],
$request->ip(), $request->userAgent());
return Response::json(['success' => true, 'sessions_killed' => $count]);
}
public function killAll(Request $request): Response
{
$user = $request->user();
if ($user['role'] !== 'super_admin') return Response::json(['error' => 'Forbidden'], 403);
$currentSessionId = $request->getAttribute('session_id', '');
if ($currentSessionId) {
$count = $this->db->query("DELETE FROM sessions WHERE id != ?", [$currentSessionId])->rowCount();
} else {
$count = $this->db->query("DELETE FROM sessions")->rowCount();
}
$this->audit->log($user, 'ALL_SESSIONS_KILLED_GLOBAL', 'system', null, 'session_management',
'/session-management', null, ['sessions_killed' => $count],
$request->ip(), $request->userAgent());
return Response::json(['success' => true, 'sessions_killed' => $count]);
}
public function loginHistory(Request $request): Response
{
$user = $request->user();
if ($user['role'] !== 'super_admin') return Response::json(['error' => 'Forbidden'], 403);
$page = max(1, (int)($request->query('page', 1)));
$perPage = 100;
$userId = $request->query('user_id');
$where = [];
$params = [];
if ($userId) {
$where[] = "la.username = (SELECT username FROM users WHERE id = ?)";
$params[] = (int)$userId;
}
$whereClause = !empty($where) ? ' WHERE ' . implode(' AND ', $where) : '';
$total = (int)$this->db->fetchColumn("SELECT COUNT(*) FROM login_attempts la{$whereClause}", $params);
$offset = ($page - 1) * $perPage;
$attempts = $this->db->fetchAll(
"SELECT la.* FROM login_attempts la{$whereClause} ORDER BY la.created_at DESC LIMIT {$perPage} OFFSET {$offset}",
$params
);
return Response::json([
'attempts' => $attempts,
'total' => $total,
'page' => $page,
'last_page' => max(1, (int)ceil($total / $perPage)),
]);
}
}
\ 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']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\SystemHealth\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Template\TemplateEngine;
final class SystemHealthController
{
private Connection $db;
private PermissionEngine $perms;
private TemplateEngine $templates;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function index(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'system_health.view');
$data = [
'user' => $user,
'active_sessions' => $this->getActiveSessions(),
'user_stats' => $this->getUserStats(),
'storage' => $this->getStorageStats(),
'database' => $this->getDatabaseStats(),
'background_jobs' => $this->getBackgroundJobs(),
'recent_errors' => $this->getRecentErrors(),
'api_activity' => $this->getApiActivity(),
'system_info' => $this->getSystemInfo(),
];
if ($request->wantsJson()) return Response::json($data);
return Response::html($this->templates->render('system_health/index', $data));
}
private function getActiveSessions(): array
{
$sessions = $this->db->fetchAll(
"SELECT s.id, s.user_id, s.ip_address, s.last_activity_at, s.created_at,
u.full_name_en, u.username, u.role
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.last_activity_at > DATE_SUB(NOW(), INTERVAL 8 HOUR)
ORDER BY s.last_activity_at DESC"
);
return ['count' => count($sessions), 'sessions' => $sessions];
}
private function getUserStats(): array
{
return $this->db->fetchAll(
"SELECT status, COUNT(*) as cnt FROM users GROUP BY status ORDER BY FIELD(status, 'active','onboarding','on_pip','suspended','terminated')"
);
}
private function getStorageStats(): array
{
$totalFiles = (int)$this->db->fetchColumn("SELECT COUNT(*) FROM file_uploads");
$totalSize = (int)$this->db->fetchColumn("SELECT COALESCE(SUM(size_bytes), 0) FROM file_uploads");
$uploadDir = ROOT_PATH . '/storage/uploads';
$diskFree = is_dir($uploadDir) ? disk_free_space($uploadDir) : 0;
$diskTotal = is_dir($uploadDir) ? disk_total_space($uploadDir) : 0;
return [
'total_files' => $totalFiles,
'total_size_bytes' => $totalSize,
'total_size_human' => $this->humanFileSize($totalSize),
'disk_free_bytes' => (int)$diskFree,
'disk_free_human' => $this->humanFileSize((int)$diskFree),
'disk_total_bytes' => (int)$diskTotal,
'disk_total_human' => $this->humanFileSize((int)$diskTotal),
'disk_used_pct' => $diskTotal > 0 ? round((($diskTotal - $diskFree) / $diskTotal) * 100, 1) : 0,
];
}
private function getDatabaseStats(): array
{
$dbSize = $this->db->fetchOne(
"SELECT ROUND(SUM(data_length + index_length) / 1048576, 2) AS size_mb
FROM information_schema.tables WHERE table_schema = DATABASE()"
);
$tableCount = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()"
);
$largestTables = $this->db->fetchAll(
"SELECT table_name, table_rows,
ROUND((data_length + index_length) / 1048576, 2) AS size_mb
FROM information_schema.tables WHERE table_schema = DATABASE()
ORDER BY data_length + index_length DESC LIMIT 10"
);
return [
'size_mb' => (float)($dbSize['size_mb'] ?? 0),
'table_count' => $tableCount,
'largest_tables' => $largestTables,
];
}
private function getBackgroundJobs(): array
{
return $this->db->fetchAll("SELECT * FROM background_jobs ORDER BY job_key");
}
private function getRecentErrors(): array
{
$logFile = ROOT_PATH . '/storage/logs/error.log';
if (!file_exists($logFile)) return [];
$lines = [];
$fp = fopen($logFile, 'r');
if ($fp) {
fseek($fp, max(0, filesize($logFile) - 50000));
while (!feof($fp)) {
$line = fgets($fp);
if ($line !== false && trim($line) !== '') {
$lines[] = trim($line);
}
}
fclose($fp);
}
return array_slice(array_reverse($lines), 0, 100);
}
private function getApiActivity(): array
{
$hourAgo = date('Y-m-d H:i:s', strtotime('-1 hour'));
$dayAgo = date('Y-m-d H:i:s', strtotime('-24 hours'));
return [
'requests_last_hour' => (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM audit_trail WHERE endpoint LIKE '/api/%' AND created_at > ?",
[$hourAgo]
),
'requests_last_24h' => (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM audit_trail WHERE endpoint LIKE '/api/%' AND created_at > ?",
[$dayAgo]
),
'unique_ips_today' => (int)$this->db->fetchColumn(
"SELECT COUNT(DISTINCT ip_address) FROM audit_trail WHERE DATE(created_at) = CURDATE()"
),
'failed_logins_today' => (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM login_attempts WHERE success = 0 AND DATE(created_at) = CURDATE()"
),
];
}
private function getSystemInfo(): array
{
return [
'php_version' => PHP_VERSION,
'php_memory_limit' => ini_get('memory_limit'),
'php_max_execution_time' => ini_get('max_execution_time'),
'php_upload_max_filesize' => ini_get('upload_max_filesize'),
'server_time' => date('Y-m-d H:i:s T'),
'timezone' => date_default_timezone_get(),
'server_software' => $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown',
'php_sapi' => PHP_SAPI,
'memory_usage' => $this->humanFileSize(memory_get_usage(true)),
'peak_memory' => $this->humanFileSize(memory_get_peak_usage(true)),
];
}
private function humanFileSize(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$factor = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
return round($bytes / pow(1024, $factor), 2) . ' ' . $units[(int)$factor];
}
}
\ 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']);
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Webhooks\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
use Modules\Webhooks\Services\WebhookDispatcher;
final class WebhookController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
}
public function index(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'webhooks.manage');
$webhooks = $this->db->fetchAll(
"SELECT w.*, u.full_name_en as created_by_name,
(SELECT COUNT(*) FROM webhook_deliveries WHERE webhook_id = w.id) as total_deliveries,
(SELECT COUNT(*) FROM webhook_deliveries WHERE webhook_id = w.id AND status = 'failed') as failed_deliveries,
(SELECT MAX(created_at) FROM webhook_deliveries WHERE webhook_id = w.id) as last_triggered_at
FROM webhooks w
JOIN users u ON u.id = w.created_by_id
ORDER BY w.created_at DESC"
);
foreach ($webhooks as &$w) {
$w['subscribed_events'] = json_decode($w['subscribed_events_json'], true);
}
if ($request->wantsJson()) {
return Response::json(['webhooks' => $webhooks]);
}
$templates = Container::getInstance()->resolve(\Engine\Template\TemplateEngine::class);
$availableEvents = require ROOT_PATH . '/config/webhook_events.php';
return Response::html($templates->render('webhooks/index', [
'user' => $user, 'webhooks' => $webhooks, 'available_events' => $availableEvents,
]));
}
public function create(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'webhooks.manage');
$url = $request->input('url');
if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) {
return Response::json(['error' => 'Valid URL is required.'], 422);
}
$events = $request->input('subscribed_events', []);
if (empty($events)) {
return Response::json(['error' => 'At least one event must be subscribed.'], 422);
}
$secret = bin2hex(random_bytes(32));
$id = $this->db->insert('webhooks', [
'url' => $url,
'secret' => $secret,
'is_active' => 1,
'subscribed_events_json' => json_encode($events),
'created_by_id' => $user['id'],
]);
$this->audit->log($user, 'WEBHOOK_CREATED', 'webhook', $id, 'webhooks', '/webhooks',
null, ['url' => $url, 'events' => $events], $request->ip(), $request->userAgent());
return Response::json([
'success' => true,
'id' => $id,
'secret' => $secret,
'warning' => 'This secret is shown ONCE. Store it securely for signature verification.',
]);
}
public function update(Request $request, string $webhookId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'webhooks.manage');
$webhook = $this->db->fetchOne("SELECT * FROM webhooks WHERE id = ?", [(int)$webhookId]);
if (!$webhook) return Response::json(['error' => 'Not found'], 404);
$data = [];
if ($request->input('url') !== null) $data['url'] = $request->input('url');
if ($request->input('is_active') !== null) $data['is_active'] = (int)$request->input('is_active');
if ($request->input('subscribed_events') !== null) {
$data['subscribed_events_json'] = json_encode($request->input('subscribed_events'));
}
if (!empty($data)) {
$this->db->update('webhooks', $data, 'id = ?', [(int)$webhookId]);
}
return Response::json(['success' => true]);
}
public function test(Request $request, string $webhookId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'webhooks.manage');
$webhook = $this->db->fetchOne("SELECT * FROM webhooks WHERE id = ?", [(int)$webhookId]);
if (!$webhook) return Response::json(['error' => 'Not found'], 404);
$dispatcher = new WebhookDispatcher($this->db);
$result = $dispatcher->dispatchSingle($webhook, 'test.ping', [
'message' => 'Webhook test from AL-ARCADE HR Platform',
'timestamp' => date('c'),
'webhook_id' => (int)$webhookId,
]);
return Response::json(['success' => true, 'delivery' => $result]);
}
public function deliveries(Request $request, string $webhookId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'webhooks.manage');
$page = max(1, (int)($request->query('page', 1)));
$perPage = 50;
$offset = ($page - 1) * $perPage;
$deliveries = $this->db->fetchAll(
"SELECT * FROM webhook_deliveries WHERE webhook_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
[(int)$webhookId, $perPage, $offset]
);
$total = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM webhook_deliveries WHERE webhook_id = ?",
[(int)$webhookId]
);
return Response::json([
'deliveries' => $deliveries,
'total' => $total,
'page' => $page,
'last_page' => (int)ceil($total / $perPage),
]);
}
public function delete(Request $request, string $webhookId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'webhooks.manage');
$this->db->delete('webhook_deliveries', 'webhook_id = ?', [(int)$webhookId]);
$this->db->delete('webhooks', 'id = ?', [(int)$webhookId]);
$this->audit->log($user, 'WEBHOOK_DELETED', 'webhook', (int)$webhookId, 'webhooks', '/webhooks',
null, null, $request->ip(), $request->userAgent());
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Webhooks\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
use Modules\Webhooks\Services\WebhookDispatcher;
final class WebhookRetryJob implements JobInterface
{
public function key(): string { return 'webhook_retry'; }
public function schedule(): string { return '*/5 * * * *'; }
public function run(): void
{
$db = Container::getInstance()->resolve(Connection::class);
$dispatcher = new WebhookDispatcher($db);
$retried = $dispatcher->retryFailed();
if ($retried > 0) {
error_log("[WebhookRetryJob] Retried {$retried} failed webhook deliveries.");
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Webhooks\Listeners;
use Engine\Events\ListenerInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
use Modules\Webhooks\Services\WebhookDispatcher;
final class DispatchWebhookListener implements ListenerInterface
{
private static array $eventMapping = [
'card.created' => 'card.created',
'card.moved' => 'card.moved',
'card.assigned' => 'card.assigned',
'card.moved_to_done' => 'card.done',
'bounty.paid' => 'bounty.paid',
'report.submitted' => 'report.submitted',
'report.unreported_detected' => 'report.missed',
'deduction.initiated' => 'deduction.created',
'deduction.applied' => 'deduction.applied',
'user.activated' => 'contractor.activated',
'user.terminated' => 'contractor.terminated',
'payroll.approved' => 'payroll.approved',
'evaluation.compiled' => 'evaluation.compiled',
];
public function handle(string $eventName, array $data): void
{
$webhookEvent = self::$eventMapping[$eventName] ?? null;
if (!$webhookEvent) return;
try {
$db = Container::getInstance()->resolve(Connection::class);
$dispatcher = new WebhookDispatcher($db);
$dispatcher->dispatch($webhookEvent, $data);
} catch (\Throwable $e) {
error_log("[WebhookListener] Failed to dispatch webhook for {$eventName}: " . $e->getMessage());
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Webhooks\Services;
use Engine\Database\Connection;
final class WebhookDispatcher
{
private Connection $db;
public function __construct(Connection $db)
{
$this->db = $db;
}
public function dispatch(string $event, array $payload): void
{
$webhooks = $this->db->fetchAll(
"SELECT * FROM webhooks WHERE is_active = 1"
);
foreach ($webhooks as $webhook) {
$subscribedEvents = json_decode($webhook['subscribed_events_json'], true) ?: [];
if (!in_array($event, $subscribedEvents)) {
continue;
}
$this->dispatchSingle($webhook, $event, $payload);
}
}
public function dispatchSingle(array $webhook, string $event, array $payload): array
{
$fullPayload = [
'event' => $event,
'timestamp' => date('c'),
'data' => $payload,
];
$jsonPayload = json_encode($fullPayload);
$signature = hash_hmac('sha256', $jsonPayload, $webhook['secret']);
$deliveryId = $this->db->insert('webhook_deliveries', [
'webhook_id' => $webhook['id'],
'event' => $event,
'payload_json' => $jsonPayload,
'attempt_number' => 1,
'status' => 'pending',
]);
$result = $this->sendRequest($webhook['url'], $jsonPayload, $signature, $event);
$this->db->update('webhook_deliveries', [
'response_code' => $result['http_code'],
'response_body' => substr($result['body'] ?? '', 0, 5000),
'status' => $result['success'] ? 'success' : 'failed',
], 'id = ?', [$deliveryId]);
return [
'delivery_id' => $deliveryId,
'http_code' => $result['http_code'],
'success' => $result['success'],
];
}
public function retryFailed(): int
{
$failedDeliveries = $this->db->fetchAll(
"SELECT wd.*, w.url, w.secret FROM webhook_deliveries wd
JOIN webhooks w ON w.id = wd.webhook_id
WHERE wd.status = 'failed' AND wd.attempt_number < 3
AND wd.created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)
AND w.is_active = 1
ORDER BY wd.created_at ASC LIMIT 50"
);
$retried = 0;
foreach ($failedDeliveries as $delivery) {
$signature = hash_hmac('sha256', $delivery['payload_json'], $delivery['secret']);
$result = $this->sendRequest($delivery['url'], $delivery['payload_json'], $signature, $delivery['event']);
$newAttempt = $delivery['attempt_number'] + 1;
$this->db->insert('webhook_deliveries', [
'webhook_id' => $delivery['webhook_id'],
'event' => $delivery['event'],
'payload_json' => $delivery['payload_json'],
'attempt_number' => $newAttempt,
'response_code' => $result['http_code'],
'response_body' => substr($result['body'] ?? '', 0, 5000),
'status' => $result['success'] ? 'success' : 'failed',
]);
if ($result['success']) {
$this->db->update('webhook_deliveries', ['status' => 'success'], 'id = ?', [$delivery['id']]);
}
$retried++;
}
return $retried;
}
private function sendRequest(string $url, string $jsonPayload, string $signature, string $event): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $jsonPayload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Webhook-Signature: sha256=' . $signature,
'X-Webhook-Event: ' . $event,
'User-Agent: AL-ARCADE-HR/3.0',
],
]);
$body = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
return [
'http_code' => $httpCode,
'body' => $body ?: $error,
'success' => $httpCode >= 200 && $httpCode < 300,
];
}
}
\ 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']);
});
\ No newline at end of file
(function() {
'use strict';
window.AnalyticsCharts = {
renderBarChart: function(canvasId, labels, datasets, options) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const width = canvas.width = canvas.parentElement.clientWidth;
const height = canvas.height = options?.height || 300;
const padding = { top: 20, right: 20, bottom: 40, left: 60 };
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
const allValues = datasets.flatMap(d => d.data);
const maxVal = Math.max(...allValues, 1);
const barGroupW = chartW / labels.length;
const barW = Math.min(30, (barGroupW - 10) / datasets.length);
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, width, height);
// Y axis
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const y = padding.top + chartH - (chartH * i / 5);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
ctx.fillStyle = '#6b7280';
ctx.font = '11px Arial';
ctx.textAlign = 'right';
ctx.fillText(Math.round(maxVal * i / 5).toLocaleString(), padding.left - 8, y + 4);
}
// Bars
const colors = ['#3b82f6', '#22c55e', '#eab308', '#ef4444', '#8b5cf6'];
datasets.forEach((dataset, di) => {
ctx.fillStyle = dataset.color || colors[di % colors.length];
dataset.data.forEach((val, i) => {
const barH = (val / maxVal) * chartH;
const x = padding.left + i * barGroupW + (barGroupW - barW * datasets.length) / 2 + di * barW;
const y = padding.top + chartH - barH;
ctx.fillRect(x, y, barW - 1, barH);
});
});
// X labels
ctx.fillStyle = '#374151';
ctx.font = '11px Arial';
ctx.textAlign = 'center';
labels.forEach((label, i) => {
const x = padding.left + i * barGroupW + barGroupW / 2;
ctx.fillText(label, x, height - 8);
});
},
renderLineChart: function(canvasId, labels, datasets, options) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const width = canvas.width = canvas.parentElement.clientWidth;
const height = canvas.height = options?.height || 250;
const padding = { top: 20, right: 20, bottom: 40, left: 60 };
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
const allValues = datasets.flatMap(d => d.data);
const maxVal = Math.max(...allValues, 1);
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, width, height);
// Grid
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = padding.top + chartH - (chartH * i / 4);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
ctx.fillStyle = '#6b7280';
ctx.font = '11px Arial';
ctx.textAlign = 'right';
ctx.fillText(Math.round(maxVal * i / 4).toLocaleString(), padding.left - 8, y + 4);
}
const colors = ['#3b82f6', '#22c55e', '#eab308', '#ef4444'];
datasets.forEach((dataset, di) => {
ctx.strokeStyle = dataset.color || colors[di % colors.length];
ctx.lineWidth = 2;
ctx.beginPath();
dataset.data.forEach((val, i) => {
const x = padding.left + (i / Math.max(1, labels.length - 1)) * chartW;
const y = padding.top + chartH - (val / maxVal) * chartH;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
// Dots
ctx.fillStyle = dataset.color || colors[di % colors.length];
dataset.data.forEach((val, i) => {
const x = padding.left + (i / Math.max(1, labels.length - 1)) * chartW;
const y = padding.top + chartH - (val / maxVal) * chartH;
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
});
});
// X labels
ctx.fillStyle = '#374151';
ctx.font = '11px Arial';
ctx.textAlign = 'center';
labels.forEach((label, i) => {
const x = padding.left + (i / Math.max(1, labels.length - 1)) * chartW;
ctx.fillText(label, x, height - 8);
});
},
renderPieChart: function(canvasId, data, options) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const size = Math.min(canvas.parentElement.clientWidth, options?.height || 250);
canvas.width = size;
canvas.height = size;
const total = data.reduce((sum, d) => sum + d.value, 0);
if (total === 0) return;
const cx = size / 2;
const cy = size / 2;
const radius = size / 2 - 20;
const colors = ['#3b82f6', '#22c55e', '#eab308', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4'];
let startAngle = -Math.PI / 2;
data.forEach((d, i) => {
const sliceAngle = (d.value / total) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fillStyle = d.color || colors[i % colors.length];
ctx.fill();
// Label
if (sliceAngle > 0.2) {
const midAngle = startAngle + sliceAngle / 2;
const labelX = cx + Math.cos(midAngle) * radius * 0.65;
const labelY = cy + Math.sin(midAngle) * radius * 0.65;
ctx.fillStyle = '#fff';
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'center';
ctx.fillText(d.label, labelX, labelY);
}
startAngle += sliceAngle;
});
}
};
})();
\ No newline at end of file
(function() {
'use strict';
const shortcuts = {
'ctrl+k': { action: openSearch, desc: 'Open search / command palette' },
'ctrl+shift+n': { action: () => navigateTo('/reports/submit'), desc: 'Submit daily report' },
'ctrl+shift+b': { action: () => navigateTo('/boards'), desc: 'Go to boards' },
'ctrl+shift+d': { action: () => navigateTo('/dashboard'), desc: 'Go to dashboard' },
'ctrl+shift+m': { action: () => navigateTo('/messages'), desc: 'Go to messages' },
'escape': { action: closeModals, desc: 'Close modals/panels' },
'?': { action: showHelpPanel, desc: 'Show keyboard shortcuts' },
};
const helpPanelHtml = `
<div id="kb-help-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:9999;display:flex;align-items:center;justify-content:center">
<div style="background:var(--bg-primary,#fff);border-radius:12px;padding:32px;max-width:500px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,0.3)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<h2 style="margin:0;font-size:1.3em">⌨️ Keyboard Shortcuts</h2>
<button onclick="document.getElementById('kb-help-overlay').style.display='none'" style="background:none;border:none;font-size:1.5em;cursor:pointer">×</button>
</div>
<table style="width:100%;border-collapse:collapse">
<tbody id="kb-shortcuts-list"></tbody>
</table>
</div>
</div>`;
function init() {
document.body.insertAdjacentHTML('beforeend', helpPanelHtml);
const list = document.getElementById('kb-shortcuts-list');
if (list) {
Object.entries(shortcuts).forEach(([combo, config]) => {
const row = document.createElement('tr');
row.innerHTML = `<td style="padding:8px 12px"><kbd style="background:#f0f0f0;border:1px solid #ccc;border-radius:4px;padding:2px 8px;font-family:monospace;font-size:0.9em">${combo}</kbd></td><td style="padding:8px 12px;color:#666">${config.desc}</td>`;
list.appendChild(row);
});
}
document.addEventListener('keydown', handleKeyDown);
}
function handleKeyDown(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
if (e.key === 'Escape') closeModals();
return;
}
const combo = buildCombo(e);
const shortcut = shortcuts[combo];
if (shortcut) {
e.preventDefault();
shortcut.action();
}
}
function buildCombo(e) {
const parts = [];
if (e.ctrlKey || e.metaKey) parts.push('ctrl');
if (e.shiftKey) parts.push('shift');
if (e.altKey) parts.push('alt');
const key = e.key.toLowerCase();
if (!['control', 'shift', 'alt', 'meta'].includes(key)) parts.push(key);
return parts.join('+');
}
function openSearch() {
const searchInput = document.querySelector('#global-search-input, [data-search-input]');
if (searchInput) {
searchInput.focus();
searchInput.select();
}
const modal = document.getElementById('search-modal');
if (modal) modal.style.display = 'flex';
}
function closeModals() {
document.querySelectorAll('[data-modal], .modal-overlay, #kb-help-overlay, #search-modal').forEach(el => {
el.style.display = 'none';
});
document.querySelectorAll('.slide-panel').forEach(el => {
el.classList.remove('open');
});
}
function showHelpPanel() {
const overlay = document.getElementById('kb-help-overlay');
if (overlay) overlay.style.display = 'flex';
}
function navigateTo(url) {
window.location.href = url;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
\ No newline at end of file
(function() {
'use strict';
const QUEUE_KEY = 'al_arcade_offline_queue';
let isOnline = navigator.onLine;
let statusEl = null;
function init() {
statusEl = document.createElement('div');
statusEl.id = 'offline-status';
statusEl.style.cssText = 'position:fixed;top:0;left:0;right:0;padding:8px 16px;background:#ef4444;color:#fff;text-align:center;font-size:0.9em;z-index:99999;display:none;transition:transform 0.3s ease';
statusEl.textContent = '⚠️ You are offline — changes will sync when reconnected';
document.body.prepend(statusEl);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
if (!navigator.onLine) handleOffline();
processQueue();
}
function handleOnline() {
isOnline = true;
statusEl.style.display = 'none';
processQueue();
}
function handleOffline() {
isOnline = false;
statusEl.style.display = 'block';
}
function getQueue() {
try {
return JSON.parse(localStorage.getItem(QUEUE_KEY) || '[]');
} catch (e) { return []; }
}
function saveQueue(queue) {
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}
function enqueue(action) {
const queue = getQueue();
queue.push({
...action,
queued_at: new Date().toISOString(),
id: Date.now() + '_' + Math.random().toString(36).substr(2, 9),
});
saveQueue(queue);
if (isOnline) processQueue();
}
async function processQueue() {
if (!isOnline) return;
const queue = getQueue();
if (queue.length === 0) return;
const remaining = [];
for (const action of queue) {
try {
const response = await fetch(action.url, {
method: action.method || 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
'X-Queued-At': action.queued_at,
},
body: action.body ? JSON.stringify(action.body) : undefined,
credentials: 'same-origin',
});
if (!response.ok && response.status >= 500) {
remaining.push(action);
}
} catch (e) {
remaining.push(action);
break;
}
}
saveQueue(remaining);
if (remaining.length > 0 && remaining.length < queue.length) {
setTimeout(processQueue, 5000);
}
}
window.OfflineQueue = { enqueue, getQueue, processQueue, isOnline: () => isOnline };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
\ No newline at end of file
<?php /** @var array $user */ /** @var array $data_sources */ ?>
<div class="report-builder">
<h1>🔨 Custom Report Builder</h1>
<div class="builder-form">
<div class="form-group">
<label>Data Source</label>
<select id="rb-source" onchange="updateSourceColumns()" class="form-control">
<option value="">Select a data source...</option>
<?php foreach ($data_sources as $key => $ds): ?>
<option value="<?= $key ?>"><?= ucfirst($key) ?></option>
<?php endforeach; ?>
</select>
</div>
<div id="rb-columns-section" style="display:none">
<div class="form-group">
<label>Columns</label>
<div id="rb-columns" class="checkbox-grid"></div>
</div>
<div class="form-group">
<label>Filters</label>
<div id="rb-filters"></div>
<button onclick="addFilter()" class="btn btn-secondary btn-sm">+ Add Filter</button>
</div>
<div class="form-row">
<div class="form-group">
<label>Group By</label>
<select id="rb-groupby" class="form-control"><option value="">None</option></select>
</div>
<div class="form-group">
<label>Aggregation</label>
<select id="rb-aggregation" class="form-control">
<option value="">None</option>
<option value="sum">SUM</option>
<option value="avg">AVG</option>
<option value="count">COUNT</option>
<option value="min">MIN</option>
<option value="max">MAX</option>
</select>
</div>
<div class="form-group">
<label>Aggregate Column</label>
<select id="rb-aggcol" class="form-control"></select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Sort By</label>
<select id="rb-sortby" class="form-control"></select>
</div>
<div class="form-group">
<label>Direction</label>
<select id="rb-sortdir" class="form-control"><option value="DESC">Descending</option><option value="ASC">Ascending</option></select>
</div>
<div class="form-group">
<label>Limit</label>
<input type="number" id="rb-limit" value="1000" min="1" max="5000" class="form-control">
</div>
</div>
<button onclick="executeReport()" class="btn btn-primary btn-lg">🚀 Run Report</button>
</div>
</div>
<div id="rb-results" style="display:none">
<div class="results-header">
<h2>Results <span id="rb-count"></span></h2>
<button onclick="exportResults()" class="btn btn-secondary">📥 Export CSV</button>
</div>
<div class="table-responsive"><table id="rb-results-table" class="data-table"></table></div>
</div>
</div>
<script>
const dataSources = <?= json_encode($data_sources) ?>;
let lastResults = [];
function updateSourceColumns() {
const source = document.getElementById('rb-source').value;
if (!source) { document.getElementById('rb-columns-section').style.display = 'none'; return; }
document.getElementById('rb-columns-section').style.display = 'block';
const ds = dataSources[source];
const cols = Object.keys(ds.columns);
document.getElementById('rb-columns').innerHTML = cols.map(c => '<label class="checkbox-label"><input type="checkbox" value="' + c + '" checked> ' + c + '</label>').join('');
const opts = cols.map(c => '<option value="' + c + '">' + c + '</option>').join('');
document.getElementById('rb-groupby').innerHTML = '<option value="">None</option>' + opts;
document.getElementById('rb-sortby').innerHTML = opts;
document.getElementById('rb-aggcol').innerHTML = (ds.aggregatable || []).map(c => '<option value="' + c + '">' + c + '</option>').join('');
}
function addFilter() {
const source = document.getElementById('rb-source').value;
if (!source) return;
const cols = Object.keys(dataSources[source].columns);
const div = document.createElement('div');
div.className = 'filter-row';
div.innerHTML = '<select class="f-col form-control">' + cols.map(c => '<option>' + c + '</option>').join('') + '</select>' +
'<select class="f-op form-control"><option>=</option><option>!=</option><option>></option><option><</option><option>>=</option><option><=</option><option>LIKE</option><option>IS NULL</option><option>IS NOT NULL</option></select>' +
'<input class="f-val form-control" placeholder="value">' +
'<button onclick="this.parentElement.remove()" class="btn btn-xs btn-danger">×</button>';
document.getElementById('rb-filters').appendChild(div);
}
function executeReport() {
const source = document.getElementById('rb-source').value;
const columns = Array.from(document.querySelectorAll('#rb-columns input:checked')).map(cb => cb.value);
const filters = Array.from(document.querySelectorAll('.filter-row')).map(row => ({
column: row.querySelector('.f-col').value,
operator: row.querySelector('.f-op').value,
value: row.querySelector('.f-val').value,
}));
fetch('/analytics/report-builder/execute', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content || ''},
body: JSON.stringify({
source, columns, filters,
group_by: document.getElementById('rb-groupby').value,
aggregation: document.getElementById('rb-aggregation').value,
agg_column: document.getElementById('rb-aggcol').value,
sort_by: document.getElementById('rb-sortby').value,
sort_dir: document.getElementById('rb-sortdir').value,
limit: parseInt(document.getElementById('rb-limit').value),
}), credentials: 'same-origin'
}).then(r => r.json()).then(data => {
lastResults = data.rows || [];
document.getElementById('rb-results').style.display = 'block';
document.getElementById('rb-count').textContent = '(' + data.count + ' rows)';
const table = document.getElementById('rb-results-table');
if (lastResults.length === 0) { table.innerHTML = '<tr><td>No results.</td></tr>'; return; }
const headers = Object.keys(lastResults[0]);
table.innerHTML = '<thead><tr>' + headers.map(h => '<th>' + h + '</th>').join('') + '</tr></thead><tbody>' +
lastResults.map(row => '<tr>' + headers.map(h => '<td>' + (row[h] ?? '') + '</td>').join('') + '</tr>').join('') + '</tbody>';
});
}
function exportResults() {
if (!lastResults.length) return;
const headers = Object.keys(lastResults[0]);
let csv = headers.join(',') + '\n';
lastResults.forEach(row => { csv += headers.map(h => '"' + String(row[h] ?? '').replace(/"/g, '""') + '"').join(',') + '\n'; });
const blob = new Blob([csv], {type: 'text/csv'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'report_' + new Date().toISOString().slice(0, 10) + '.csv';
a.click();
}
</script>
\ No newline at end of file
<?php /** @var array $user */ /** @var array $api_keys */ ?>
<div class="api-keys-page">
<div class="page-header">
<h1>🗝️ API Keys</h1>
<button onclick="document.getElementById('create-key-modal').style.display='flex'" class="btn btn-primary">+ Generate API Key</button>
</div>
<table class="data-table">
<thead><tr><th>Name</th><th>Prefix</th><th>Scope</th><th>Rate Limit</th><th>Last Used</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach ($api_keys as $k): ?>
<tr>
<td><?= htmlspecialchars($k['name']) ?></td>
<td><code><?= $k['key_prefix'] ?>...</code></td>
<td><span class="scope-badge scope-<?= $k['scope'] ?>"><?= $k['scope'] ?></span></td>
<td><?= number_format($k['rate_limit_per_hour']) ?>/hr</td>
<td><?= $k['last_used_at'] ?? 'Never' ?></td>
<td><?= $k['revoked_at'] ? '🔴 Revoked' : '🟢 Active' ?></td>
<td><?= $k['created_at'] ?></td>
<td>
<?php if (!$k['revoked_at']): ?>
<button onclick="revokeKey(<?= $k['id'] ?>)" class="btn btn-xs btn-warning">Revoke</button>
<?php endif; ?>
<button onclick="deleteKey(<?= $k['id'] ?>)" class="btn btn-xs btn-danger">Delete</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div id="create-key-modal" class="modal-overlay" style="display:none">
<div class="modal-content">
<h3>Generate API Key</h3>
<form onsubmit="createKey(event)">
<div class="form-group"><label>Name</label><input type="text" name="name" required class="form-control" placeholder="My Integration"></div>
<div class="form-group"><label>Scope</label>
<select name="scope" class="form-control">
<option value="read_only">Read Only</option>
<option value="read_write">Read/Write</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group"><label>Rate Limit (per hour)</label><input type="number" name="rate_limit" value="1000" class="form-control"></div>
<button type="submit" class="btn btn-primary">Generate</button>
<button type="button" onclick="this.closest('.modal-overlay').style.display='none'" class="btn btn-secondary">Cancel</button>
</form>
</div>
</div>
<script>
function createKey(e) {
e.preventDefault();
const form = e.target;
fetch('/api-keys', {
method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''},
body: JSON.stringify({name: form.name.value, scope: form.scope.value, rate_limit_per_hour: parseInt(form.rate_limit.value)}),
credentials: 'same-origin'
}).then(r => r.json()).then(data => {
if (data.api_key) {
prompt('Your API Key (COPY NOW — shown once only):', data.api_key);
}
location.reload();
});
}
function revokeKey(id) {
if (!confirm('Revoke this API key? It will stop working immediately.')) return;
fetch('/api-keys/' + id + '/revoke', {method:'POST', headers:{'X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''}, credentials:'same-origin'}).then(() => location.reload());
}
function deleteKey(id) {
if (!confirm('Permanently delete this API key record?')) return;
fetch('/api-keys/' + id, {method:'DELETE', headers:{'X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''}, credentials:'same-origin'}).then(() => location.reload());
}
</script>
\ No newline at end of file
<?php /** @var array $user */ /** @var array $entries */ /** @var int $total */ /** @var int $page */ /** @var int $last_page */ /** @var array $actions */ /** @var array $entity_types */ /** @var array $modules */ ?>
<div class="audit-trail">
<h1>🔍 Audit Trail</h1>
<p class="subtitle"><?= number_format($total) ?> total entries — Immutable. Append-only. Forever.</p>
<form method="get" class="audit-filters">
<input type="text" name="search" value="<?= htmlspecialchars($_GET['search'] ?? '') ?>" placeholder="Search..." class="form-control">
<select name="action" class="form-control">
<option value="">All Actions</option>
<?php foreach ($actions as $a): ?><option value="<?= $a ?>" <?= ($_GET['action'] ?? '') === $a ? 'selected' : '' ?>><?= $a ?></option><?php endforeach; ?>
</select>
<select name="entity_type" class="form-control">
<option value="">All Entities</option>
<?php foreach ($entity_types as $et): ?><option value="<?= $et ?>" <?= ($_GET['entity_type'] ?? '') === $et ? 'selected' : '' ?>><?= $et ?></option><?php endforeach; ?>
</select>
<select name="module" class="form-control">
<option value="">All Modules</option>
<?php foreach ($modules as $m): ?><option value="<?= $m ?>" <?= ($_GET['module'] ?? '') === $m ? 'selected' : '' ?>><?= $m ?></option><?php endforeach; ?>
</select>
<input type="date" name="date_from" value="<?= $_GET['date_from'] ?? '' ?>" class="form-control">
<input type="date" name="date_to" value="<?= $_GET['date_to'] ?? '' ?>" class="form-control">
<button type="submit" class="btn btn-primary">Filter</button>
<a href="/audit-trail" class="btn btn-secondary">Clear</a>
</form>
<div class="table-responsive">
<table class="data-table audit-table">
<thead>
<tr><th>ID</th><th>Time</th><th>User</th><th>Role</th><th>Action</th><th>Entity</th><th>ID</th><th>Module</th><th>IP</th></tr>
</thead>
<tbody>
<?php foreach ($entries as $e): ?>
<tr>
<td><?= $e['id'] ?></td>
<td class="nowrap"><?= $e['created_at'] ?></td>
<td><?= htmlspecialchars($e['username'] ?? 'system') ?></td>
<td><?= $e['user_role'] ?? '-' ?></td>
<td><span class="action-badge"><?= htmlspecialchars($e['action']) ?></span></td>
<td><?= htmlspecialchars($e['entity_type']) ?></td>
<td><?= $e['entity_id'] ?? '-' ?></td>
<td><?= htmlspecialchars($e['module']) ?></td>
<td class="nowrap"><?= $e['ip_address'] ?? '-' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($last_page > 1): ?>
<div class="pagination">
<?php if ($page > 1): ?><a href="?<?= http_build_query(array_merge($_GET, ['page' => $page - 1])) ?>" class="btn btn-secondary">← Prev</a><?php endif; ?>
<span>Page <?= $page ?> / <?= $last_page ?></span>
<?php if ($page < $last_page): ?><a href="?<?= http_build_query(array_merge($_GET, ['page' => $page + 1])) ?>" class="btn btn-secondary">Next →</a><?php endif; ?>
</div>
<?php endif; ?>
<div class="export-actions">
<form method="post" action="/export/audit-trail" style="display:inline">
<input type="hidden" name="date_from" value="<?= $_GET['date_from'] ?? date('Y-m-01') ?>">
<input type="hidden" name="date_to" value="<?= $_GET['date_to'] ?? date('Y-m-d') ?>">
<input type="hidden" name="format" value="csv">
<button type="submit" class="btn btn-secondary">📥 Export CSV</button>
</form>
<form method="post" action="/export/audit-trail" style="display:inline">
<input type="hidden" name="date_from" value="<?= $_GET['date_from'] ?? date('Y-m-01') ?>">
<input type="hidden" name="date_to" value="<?= $_GET['date_to'] ?? date('Y-m-d') ?>">
<input type="hidden" name="format" value="json">
<button type="submit" class="btn btn-secondary">📥 Export JSON</button>
</form>
</div>
</div>
\ No newline at end of file
<?php /** @var string $entity */ /** @var array $config */ /** @var array $rows */ /** @var int $total */ /** @var int $page */ /** @var int $last_page */ /** @var array $user */ ?>
<div class="entity-manager">
<div class="page-header">
<h1><?= htmlspecialchars($config['label']) ?> Management</h1>
<span class="badge"><?= number_format($total) ?> records</span>
<a href="/control-panel" class="btn btn-secondary">← Back to Control Panel</a>
</div>
<div class="toolbar">
<form method="get" class="search-form">
<input type="text" name="search" value="<?= htmlspecialchars($search ?? '') ?>" placeholder="Search..." class="input-search">
<button type="submit" class="btn btn-primary">Search</button>
</form>
<div class="toolbar-actions">
<a href="/export/csv" onclick="exportEntity('<?= $entity ?>')" class="btn btn-secondary">📥 Export CSV</a>
</div>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th><input type="checkbox" id="select-all" onchange="toggleSelectAll(this)"></th>
<?php foreach ($config['columns'] as $col): ?>
<th>
<a href="?sort=<?= $col ?>&dir=<?= ($sort === $col && $dir === 'ASC') ? 'DESC' : 'ASC' ?>&search=<?= urlencode($search ?? '') ?>">
<?= htmlspecialchars($col) ?>
<?php if ($sort === $col): ?><?= $dir === 'ASC' ? '▲' : '▼' ?><?php endif; ?>
</a>
</th>
<?php endforeach; ?>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr><td colspan="<?= count($config['columns']) + 2 ?>" class="text-center text-muted">No records found.</td></tr>
<?php else: ?>
<?php foreach ($rows as $row): ?>
<tr data-id="<?= $row['id'] ?>">
<td><input type="checkbox" class="row-checkbox" value="<?= $row['id'] ?>"></td>
<?php foreach ($config['columns'] as $col): ?>
<td><?= htmlspecialchars((string)($row[$col] ?? '')) ?></td>
<?php endforeach; ?>
<td class="actions-cell">
<a href="/control-panel/<?= $entity ?>/<?= $row['id'] ?>" class="btn btn-xs btn-info">View</a>
<?php if (!empty($config['editable'])): ?>
<button onclick="editEntity('<?= $entity ?>', <?= $row['id'] ?>)" class="btn btn-xs btn-warning">Edit</button>
<?php endif; ?>
<button onclick="deleteEntity('<?= $entity ?>', <?= $row['id'] ?>)" class="btn btn-xs btn-danger">Delete</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($last_page > 1): ?>
<div class="pagination">
<?php if ($page > 1): ?>
<a href="?page=<?= $page - 1 ?>&sort=<?= $sort ?>&dir=<?= $dir ?>&search=<?= urlencode($search ?? '') ?>" class="btn btn-secondary">← Previous</a>
<?php endif; ?>
<span class="page-info">Page <?= $page ?> of <?= $last_page ?></span>
<?php if ($page < $last_page): ?>
<a href="?page=<?= $page + 1 ?>&sort=<?= $sort ?>&dir=<?= $dir ?>&search=<?= urlencode($search ?? '') ?>" class="btn btn-secondary">Next →</a>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="bulk-actions" id="bulk-actions" style="display:none">
<span id="selected-count">0</span> selected —
<button onclick="bulkDelete('<?= $entity ?>')" class="btn btn-danger btn-sm">Delete Selected</button>
</div>
</div>
<script>
function toggleSelectAll(el) {
document.querySelectorAll('.row-checkbox').forEach(cb => cb.checked = el.checked);
updateBulkBar();
}
document.querySelectorAll('.row-checkbox').forEach(cb => cb.addEventListener('change', updateBulkBar));
function updateBulkBar() {
const checked = document.querySelectorAll('.row-checkbox:checked');
const bar = document.getElementById('bulk-actions');
document.getElementById('selected-count').textContent = checked.length;
bar.style.display = checked.length > 0 ? 'block' : 'none';
}
function editEntity(entity, id) {
fetch('/control-panel/' + entity + '/' + id).then(r => r.json()).then(data => {
const fields = data.editable_fields || [];
const record = data.record || {};
let html = '<form id="edit-form">';
fields.forEach(f => {
html += '<div class="form-group"><label>' + f + '</label><input name="' + f + '" value="' + (record[f] || '') + '" class="form-control"></div>';
});
html += '<button type="submit" class="btn btn-primary">Save</button></form>';
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = '<div class="modal-content"><h3>Edit ' + entity + ' #' + id + '</h3>' + html + '<button onclick="this.closest(\'.modal-overlay\').remove()" class="btn btn-secondary mt-2">Cancel</button></div>';
document.body.appendChild(modal);
modal.querySelector('form').onsubmit = function(e) {
e.preventDefault();
const formData = Object.fromEntries(new FormData(this));
fetch('/control-panel/' + entity + '/' + id, {
method: 'PUT', headers: {'Content-Type':'application/json','X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''},
body: JSON.stringify(formData), credentials: 'same-origin'
}).then(r => r.json()).then(() => { modal.remove(); location.reload(); });
};
});
}
function deleteEntity(entity, id) {
if (!confirm('Are you sure you want to delete this record?')) return;
fetch('/control-panel/' + entity + '/' + id, {
method: 'DELETE', headers: {'Content-Type':'application/json','X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''},
credentials: 'same-origin'
}).then(r => r.json()).then(() => location.reload());
}
function bulkDelete(entity) {
const ids = Array.from(document.querySelectorAll('.row-checkbox:checked')).map(cb => parseInt(cb.value));
if (!confirm('Delete ' + ids.length + ' records? This cannot be undone.')) return;
fetch('/control-panel/' + entity + '/bulk-delete', {
method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''},
body: JSON.stringify({ids}), credentials: 'same-origin'
}).then(r => r.json()).then(() => location.reload());
}
</script>
\ No newline at end of file
<?php /** @var array $user */ /** @var array $counts */ ?>
<div class="control-panel">
<div class="page-header">
<h1>🎮 Super Admin Control Panel</h1>
<p class="subtitle">God mode. Every entity. Full CRUD. No limits.</p>
</div>
<div class="entity-grid">
<?php
$entities = [
['key' => 'users', 'icon' => '👥', 'label' => 'Users', 'count' => $counts['users']],
['key' => 'boards', 'icon' => '📋', 'label' => 'Boards', 'count' => $counts['boards']],
['key' => 'cards', 'icon' => '🃏', 'label' => 'Cards', 'count' => $counts['cards']],
['key' => 'deductions', 'icon' => '💸', 'label' => 'Deductions', 'count' => $counts['deductions']],
['key' => 'daily_reports', 'icon' => '📝', 'label' => 'Reports', 'count' => $counts['reports']],
['key' => 'payroll_records', 'icon' => '💰', 'label' => 'Payroll', 'count' => $counts['payroll']],
['key' => 'evaluations', 'icon' => '⭐', 'label' => 'Evaluations', 'count' => $counts['evaluations']],
['key' => 'pips', 'icon' => '📊', 'label' => 'PIPs', 'count' => $counts['pips']],
['key' => 'meetings', 'icon' => '📅', 'label' => 'Meetings', 'count' => $counts['meetings']],
['key' => 'holidays', 'icon' => '🏖️', 'label' => 'Holidays', 'count' => $counts['holidays']],
['key' => 'notices', 'icon' => '📢', 'label' => 'Notices', 'count' => $counts['notices']],
['key' => 'policies', 'icon' => '📜', 'label' => 'Policies', 'count' => $counts['policies']],
['key' => 'contracts', 'icon' => '✍️', 'label' => 'Contracts', 'count' => $counts['contracts']],
['key' => 'invites', 'icon' => '✉️', 'label' => 'Invites', 'count' => 0],
['key' => 'learning_goals', 'icon' => '🎯', 'label' => 'Learning Goals', 'count' => 0],
['key' => 'manual_adjustments', 'icon' => '🔧', 'label' => 'Adjustments', 'count' => 0],
];
foreach ($entities as $e): ?>
<a href="/control-panel/<?= $e['key'] ?>" class="entity-card">
<span class="entity-icon"><?= $e['icon'] ?></span>
<span class="entity-label"><?= htmlspecialchars($e['label']) ?></span>
<span class="entity-count"><?= number_format($e['count']) ?></span>
</a>
<?php endforeach; ?>
</div>
<div class="quick-links">
<h2>Quick Access</h2>
<div class="link-grid">
<a href="/analytics" class="quick-link">📊 Analytics Dashboard</a>
<a href="/analytics/report-builder" class="quick-link">🔨 Report Builder</a>
<a href="/audit-trail" class="quick-link">🔍 Audit Trail</a>
<a href="/system-health" class="quick-link">🏥 System Health</a>
<a href="/session-management/sessions" class="quick-link">🔑 Active Sessions</a>
<a href="/api-keys" class="quick-link">🗝️ API Keys</a>
<a href="/webhooks" class="quick-link">🔗 Webhooks</a>
<a href="/settings" class="quick-link">⚙️ System Settings</a>
</div>
</div>
</div>
\ No newline at end of file
<?php /** @var array $user */ /** @var array $active_sessions */ /** @var array $user_stats */ /** @var array $storage */ /** @var array $database */ /** @var array $background_jobs */ /** @var array $api_activity */ /** @var array $system_info */ ?>
<div class="system-health">
<h1>🏥 System Health Dashboard</h1>
<div class="health-grid">
<div class="health-card">
<h3>👥 Active Sessions</h3>
<div class="big-number"><?= $active_sessions['count'] ?></div>
<ul class="session-list">
<?php foreach (array_slice($active_sessions['sessions'], 0, 10) as $s): ?>
<li><?= htmlspecialchars($s['full_name_en']) ?> (<?= $s['role'] ?>) — <?= $s['ip_address'] ?><?= $s['last_activity_at'] ?></li>
<?php endforeach; ?>
</ul>
</div>
<div class="health-card">
<h3>👤 User Status Breakdown</h3>
<?php foreach ($user_stats as $stat): ?>
<div class="stat-row"><span><?= htmlspecialchars($stat['status']) ?></span><strong><?= $stat['cnt'] ?></strong></div>
<?php endforeach; ?>
</div>
<div class="health-card">
<h3>💾 Storage</h3>
<div class="stat-row"><span>Files</span><strong><?= number_format($storage['total_files']) ?></strong></div>
<div class="stat-row"><span>Used</span><strong><?= $storage['total_size_human'] ?></strong></div>
<div class="stat-row"><span>Disk Free</span><strong><?= $storage['disk_free_human'] ?></strong></div>
<div class="stat-row"><span>Disk Used</span><strong><?= $storage['disk_used_pct'] ?>%</strong></div>
</div>
<div class="health-card">
<h3>🗄️ Database</h3>
<div class="stat-row"><span>Size</span><strong><?= $database['size_mb'] ?> MB</strong></div>
<div class="stat-row"><span>Tables</span><strong><?= $database['table_count'] ?></strong></div>
<h4>Largest Tables</h4>
<?php foreach (array_slice($database['largest_tables'], 0, 5) as $t): ?>
<div class="stat-row"><span><?= $t['table_name'] ?></span><strong><?= $t['size_mb'] ?> MB (<?= number_format($t['table_rows']) ?> rows)</strong></div>
<?php endforeach; ?>
</div>
<div class="health-card">
<h3>🔌 API Activity</h3>
<div class="stat-row"><span>Requests (last hour)</span><strong><?= number_format($api_activity['requests_last_hour']) ?></strong></div>
<div class="stat-row"><span>Requests (24h)</span><strong><?= number_format($api_activity['requests_last_24h']) ?></strong></div>
<div class="stat-row"><span>Unique IPs today</span><strong><?= $api_activity['unique_ips_today'] ?></strong></div>
<div class="stat-row"><span>Failed logins today</span><strong><?= $api_activity['failed_logins_today'] ?></strong></div>
</div>
<div class="health-card">
<h3>⚙️ System Info</h3>
<?php foreach ($system_info as $key => $val): ?>
<div class="stat-row"><span><?= str_replace('_', ' ', $key) ?></span><strong><?= htmlspecialchars((string)$val) ?></strong></div>
<?php endforeach; ?>
</div>
<div class="health-card full-width">
<h3>⏰ Background Jobs</h3>
<table class="data-table">
<thead><tr><th>Job</th><th>Status</th><th>Last Run</th><th>Next Run</th><th>Enabled</th><th>Last Error</th></tr></thead>
<tbody>
<?php foreach ($background_jobs as $job): ?>
<tr>
<td><?= htmlspecialchars($job['job_key']) ?></td>
<td>
<?php if ($job['last_status'] === 'success'): ?>🟢
<?php elseif ($job['last_status'] === 'failed'): ?>🔴
<?php elseif ($job['last_status'] === 'running'): ?>🟡
<?php else: ?><?php endif; ?>
<?= $job['last_status'] ?? 'never' ?>
</td>
<td><?= $job['last_run_at'] ?? 'Never' ?></td>
<td><?= $job['next_run_at'] ?? 'N/A' ?></td>
<td><?= $job['is_enabled'] ? '✅' : '❌' ?></td>
<td class="error-cell"><?= htmlspecialchars(substr($job['last_error'] ?? '', 0, 100)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
\ No newline at end of file
<?php /** @var array $user */ /** @var array $webhooks */ /** @var array $available_events */ ?>
<div class="webhooks-page">
<div class="page-header">
<h1>🔗 Webhooks</h1>
<button onclick="document.getElementById('create-webhook-modal').style.display='flex'" class="btn btn-primary">+ Create Webhook</button>
</div>
<table class="data-table">
<thead><tr><th>URL</th><th>Events</th><th>Active</th><th>Deliveries</th><th>Failed</th><th>Last Triggered</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach ($webhooks as $w): ?>
<tr>
<td class="url-cell"><?= htmlspecialchars($w['url']) ?></td>
<td><?= count($w['subscribed_events'] ?? []) ?> events</td>
<td><?= $w['is_active'] ? '🟢 Active' : '🔴 Inactive' ?></td>
<td><?= number_format($w['total_deliveries']) ?></td>
<td><?= number_format($w['failed_deliveries']) ?></td>
<td><?= $w['last_triggered_at'] ?? 'Never' ?></td>
<td>
<button onclick="testWebhook(<?= $w['id'] ?>)" class="btn btn-xs btn-info">Test</button>
<a href="/webhooks/<?= $w['id'] ?>/deliveries" class="btn btn-xs btn-secondary">Log</a>
<button onclick="deleteWebhook(<?= $w['id'] ?>)" class="btn btn-xs btn-danger">Delete</button>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($webhooks)): ?>
<tr><td colspan="7" class="text-center text-muted">No webhooks configured.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<div id="create-webhook-modal" class="modal-overlay" style="display:none">
<div class="modal-content">
<h3>Create Webhook</h3>
<form onsubmit="createWebhook(event)">
<div class="form-group"><label>URL</label><input type="url" name="url" required class="form-control" placeholder="https://example.com/webhook"></div>
<div class="form-group"><label>Events</label>
<div class="checkbox-grid">
<?php foreach ($available_events as $event): ?>
<label class="checkbox-label"><input type="checkbox" name="events[]" value="<?= $event ?>"> <?= $event ?></label>
<?php endforeach; ?>
</div>
</div>
<button type="submit" class="btn btn-primary">Create</button>
<button type="button" onclick="this.closest('.modal-overlay').style.display='none'" class="btn btn-secondary">Cancel</button>
</form>
</div>
</div>
<script>
function createWebhook(e) {
e.preventDefault();
const form = e.target;
const url = form.querySelector('[name=url]').value;
const events = Array.from(form.querySelectorAll('[name="events[]"]:checked')).map(cb => cb.value);
fetch('/webhooks', {
method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''},
body: JSON.stringify({url, subscribed_events: events}), credentials: 'same-origin'
}).then(r => r.json()).then(data => {
if (data.secret) alert('Webhook secret (SAVE THIS): ' + data.secret);
location.reload();
});
}
function testWebhook(id) {
fetch('/webhooks/' + id + '/test', {
method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''},
credentials: 'same-origin'
}).then(r => r.json()).then(data => alert(data.delivery?.success ? 'Test successful!' : 'Test failed. Check delivery log.'));
}
function deleteWebhook(id) {
if (!confirm('Delete this webhook?')) return;
fetch('/webhooks/' + id, {
method: 'DELETE', headers: {'X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''},
credentials: 'same-origin'
}).then(() => location.reload());
}
</script>
\ 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