Files
root 2d5b151234
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 2m15s
Tests / PHPUnit (push) Successful in 2m23s
fix navbar and age mismatch
2026-09-07 18:06:37 -04:00

562 lines
18 KiB
PHP

<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\NavItemModel;
use App\Models\RoleNavItemModel;
use App\Services\NavbarService;
class NavBuilderController extends BaseController
{
protected NavItemModel $items;
protected RoleNavItemModel $maps;
protected NavbarService $service;
public function __construct()
{
$this->items = new NavItemModel();
$this->maps = new RoleNavItemModel();
$this->service = new NavbarService();
}
protected function ensureAdmin(): void
{
$session = session();
$roles = array_filter(array_merge(
(array) $session->get('roles'),
(array) $session->get('role')
));
$normalizedRoles = array_map(static fn ($role) => strtolower(trim((string) $role)), $roles);
if (!in_array('administrator', $normalizedRoles, true)) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
}
public function index(){
$this->ensureAdmin();
helper(['url', 'form']);
return view('nav_builder/index');
}
/** Case-insensitive, natural sort; ignores leading punctuation & articles (“a/an/the”) */
private function labelKey(string $s): string
{
$s = trim(preg_replace('/\s+/', ' ', $s));
$s = preg_replace('/^[^[:alnum:]]+/u', '', $s); // strip leading punctuation
$s = preg_replace('/^(?i)(the|an|a)\s+/', '', $s); // drop leading articles
return mb_strtolower($s, 'UTF-8');
}
private function sortTreeAlpha(array &$nodes): void
{
usort($nodes, fn($a,$b) => strnatcasecmp(
$this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')
));
foreach ($nodes as &$n) {
if (!empty($n['children'])) {
$this->sortTreeAlpha($n['children']);
}
}
unset($n);
}
public function save()
{
$this->ensureAdmin();
$id = (int) $this->request->getPost('id');
// Accept either "menu_parent_id" or "parent_id" from the form
$menuParentRaw = $this->request->getPost('menu_parent_id');
if ($menuParentRaw === null) {
$menuParentRaw = $this->request->getPost('parent_id');
}
$menuParentId = ($menuParentRaw === '' || $menuParentRaw === null) ? null : (int) $menuParentRaw;
if ($id && $menuParentId === $id) {
// item cannot be its own parent
$menuParentId = null;
}
// Validate parent exists (optional but helpful)
if ($menuParentId !== null) {
$parent = $this->items->select('id')->where('id', $menuParentId)->first();
if (!$parent) {
if ($this->wantsJson()) {
return $this->response
->setStatusCode(422)
->setJSON([
'ok' => false,
'message' => 'Selected parent does not exist.',
'csrf' => $this->csrfPayload(),
]);
}
return redirect()->back()->with('error', 'Selected parent does not exist.')->withInput();
}
}
$data = [
'menu_parent_id' => $menuParentId,
'label' => trim((string) $this->request->getPost('label')),
'url' => trim((string) $this->request->getPost('url')) ?: null,
'icon_class' => trim((string) $this->request->getPost('icon_class')) ?: null,
'target' => trim((string) $this->request->getPost('target')) ?: null,
'sort_order' => (int) $this->request->getPost('sort_order'),
'is_enabled' => (int) ($this->request->getPost('is_enabled') ? 1 : 0),
];
// Save (force insert to return ID)
if ($id) {
$this->items->update($id, $data);
} else {
$id = (int) $this->items->insert($data, true); // ensure insertID is returned
}
// Roles
$roleIds = array_values(array_unique(array_filter(
array_map('intval', (array) ($this->request->getPost('roles') ?? [])),
fn ($v) => $v > 0
)));
$this->maps->where('nav_item_id', $id)->delete();
foreach ($roleIds as $rid) {
$this->maps->insert(['role_id' => $rid, 'nav_item_id' => $id]);
}
$this->service->clearCache();
if ($this->wantsJson()) {
return $this->response->setJSON([
'ok' => true,
'message' => 'Menu saved.',
'id' => $id,
'csrf' => $this->csrfPayload(),
'payload' => $this->buildNavPayload(),
]);
}
return redirect()->back()->with('success', 'Menu saved.');
}
private function wantsJson(): bool
{
return $this->request->isAJAX()
|| str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json');
}
private function csrfPayload(): array
{
return [
'name' => csrf_token(),
'hash' => csrf_hash(),
];
}
public function delete($id)
{
$this->ensureAdmin();
$id = (int) $id;
$this->items->delete($id); // children handled by FK (SET NULL on parent)
$this->service->clearCache();
return redirect()->back()->with('success', 'Menu item deleted.');
}
public function reorder()
{
$this->ensureAdmin();
$structure = $this->request->getPost('structure') ?? [];
if (is_array($structure) && !empty($structure)) {
$updates = $this->normalizeStructureUpdates($structure);
foreach ($updates as $row) {
$this->items->update($row['id'], [
'menu_parent_id' => $row['menu_parent_id'],
'sort_order' => $row['sort_order'],
]);
}
$this->service->clearCache();
return $this->response->setJSON(['ok' => true, 'csrf' => $this->csrfPayload()]);
}
// Backward-compatible payload used by the previous builder.
$orders = $this->request->getPost('orders') ?? [];
foreach ($orders as $id => $order) {
$this->items->update((int) $id, ['sort_order' => (int) $order]);
}
$this->service->clearCache();
return $this->response->setJSON(['ok' => true, 'csrf' => $this->csrfPayload()]);
}
public function roleAccess()
{
$this->ensureAdmin();
$action = strtolower(trim((string) $this->request->getPost('action')));
$navItemId = (int) $this->request->getPost('nav_item_id');
$sourceRoleId = (int) $this->request->getPost('source_role_id');
$targetRoleId = (int) $this->request->getPost('target_role_id');
if ($navItemId <= 0 || !$this->items->select('id')->find($navItemId)) {
return $this->response
->setStatusCode(422)
->setJSON(['ok' => false, 'message' => 'Selected page does not exist.', 'csrf' => $this->csrfPayload()]);
}
if ($action === 'remove') {
if (!$this->roleExists($sourceRoleId)) {
return $this->response
->setStatusCode(422)
->setJSON(['ok' => false, 'message' => 'Selected role does not exist.', 'csrf' => $this->csrfPayload()]);
}
$this->maps
->where('nav_item_id', $navItemId)
->where('role_id', $sourceRoleId)
->delete();
} elseif ($action === 'move') {
if (!$this->roleExists($sourceRoleId) || !$this->roleExists($targetRoleId)) {
return $this->response
->setStatusCode(422)
->setJSON(['ok' => false, 'message' => 'Selected role does not exist.', 'csrf' => $this->csrfPayload()]);
}
if ($sourceRoleId !== $targetRoleId) {
$this->maps
->where('nav_item_id', $navItemId)
->where('role_id', $sourceRoleId)
->delete();
$exists = $this->maps
->where('nav_item_id', $navItemId)
->where('role_id', $targetRoleId)
->first();
if (!$exists) {
$this->maps->insert([
'role_id' => $targetRoleId,
'nav_item_id' => $navItemId,
]);
}
}
} else {
return $this->response
->setStatusCode(422)
->setJSON(['ok' => false, 'message' => 'Unsupported role access action.', 'csrf' => $this->csrfPayload()]);
}
$this->service->clearCache();
return $this->response->setJSON([
'ok' => true,
'message' => 'Role access updated.',
'csrf' => $this->csrfPayload(),
'payload' => $this->buildNavPayload(),
]);
}
private function roleExists(int $roleId): bool
{
if ($roleId <= 0) {
return false;
}
$db = \Config\Database::connect();
return (bool) $db->table('roles')
->select('id')
->where('id', $roleId)
->get(1)
->getFirstRow();
}
private function normalizeStructureUpdates(array $structure): array
{
$existingRows = $this->items->select('id, menu_parent_id')->findAll();
$existingIds = array_map('intval', array_column($existingRows, 'id'));
$existingIdLookup = array_fill_keys($existingIds, true);
$updates = [];
foreach ($structure as $row) {
if (!is_array($row)) {
continue;
}
$id = (int) ($row['id'] ?? 0);
if ($id <= 0 || !isset($existingIdLookup[$id])) {
continue;
}
$parentId = $row['parent_id'] ?? null;
$parentId = ($parentId === '' || $parentId === null) ? null : (int) $parentId;
if ($parentId !== null && (!isset($existingIdLookup[$parentId]) || $parentId === $id)) {
$parentId = null;
}
$updates[] = [
'id' => $id,
'menu_parent_id' => $parentId,
'sort_order' => max(0, (int) ($row['sort_order'] ?? 0)),
];
}
$updatesById = [];
foreach ($updates as $row) {
$updatesById[$row['id']] = $row;
}
foreach ($updatesById as $id => &$row) {
if ($row['menu_parent_id'] !== null && $this->wouldCreateCycle($id, $row['menu_parent_id'], $updatesById, $existingRows)) {
$row['menu_parent_id'] = null;
}
}
unset($row);
return array_values($updatesById);
}
private function wouldCreateCycle(int $id, int $parentId, array $updatesById, array $existingRows): bool
{
$parentById = [];
foreach ($existingRows as $row) {
$parentById[(int) ($row['id'] ?? 0)] = isset($row['menu_parent_id']) && (int) $row['menu_parent_id'] !== 0
? (int) $row['menu_parent_id']
: null;
}
foreach ($updatesById as $row) {
$parentById[$row['id']] = $row['menu_parent_id'];
}
$seen = [];
$current = $parentId;
while ($current !== null) {
if ($current === $id) {
return true;
}
if (isset($seen[$current])) {
return true;
}
$seen[$current] = true;
$current = $parentById[$current] ?? null;
}
return false;
}
protected function distinctRoles(): array
{
$db = \Config\Database::connect();
return $db->table('roles')
->select('id, name')
->orderBy('name')
->get()
->getResultArray();
}
public function data()
{
$this->ensureAdmin();
return $this->response->setJSON($this->buildNavPayload());
}
private function buildNavPayload(): array
{
$all = $this->items
->orderBy('menu_parent_id', 'ASC')
->orderBy('label', 'ASC')
->orderBy('id', 'ASC')
->findAll();
$byId = [];
foreach ($all as $a) {
$a['children'] = [];
$byId[$a['id']] = $a;
}
$tree = [];
foreach ($byId as $id => &$node) {
$pid = $node['menu_parent_id'] ?: null;
if ($pid && isset($byId[$pid])) {
$byId[$pid]['children'][] = &$node;
} else {
$tree[] = &$node;
}
}
unset($node);
$this->sortTreeAlpha($tree);
$flatAlpha = $all;
usort($flatAlpha, fn($a, $b) => strnatcasecmp(
$this->labelKey($a['label'] ?? ''),
$this->labelKey($b['label'] ?? '')
));
$roleAssignments = [];
$roleRows = $this->maps
->select('role_nav_items.nav_item_id, roles.id AS role_id, roles.name AS role_name')
->join('roles', 'roles.id = role_nav_items.role_id', 'left')
->findAll();
foreach ($roleRows as $row) {
$navId = (int) ($row['nav_item_id'] ?? 0);
if ($navId <= 0) continue;
$roleId = (int) ($row['role_id'] ?? 0);
$roleName = $row['role_name'] ?? ($roleId ? ('#' . $roleId) : null);
if ($roleId > 0) {
$roleAssignments[$navId]['ids'][] = $roleId;
}
if ($roleName !== null) {
$roleAssignments[$navId]['names'][] = $roleName;
}
}
$flattened = $this->flattenTreeForResponse($tree, $roleAssignments);
$parentOptions = array_map(static function ($row) {
return [
'id' => (int) ($row['id'] ?? 0),
'label' => (string) ($row['label'] ?? ''),
];
}, $flatAlpha);
$roles = array_map(static function ($role) {
return [
'id' => (int) ($role['id'] ?? 0),
'name' => (string) ($role['name'] ?? ''),
];
}, $this->distinctRoles());
return [
'items' => $flattened,
'roles' => $roles,
'parentOptions' => $parentOptions,
'routeOptions' => $this->routeOptions(),
];
}
private function routeOptions(): array
{
$routes = service('routes');
$getRoutes = $routes->getRoutes('GET', false);
if (empty($getRoutes)) {
$routes = $routes->loadRoutes();
$getRoutes = $routes->getRoutes('GET', false);
}
$options = [];
foreach (array_keys($getRoutes) as $route) {
$route = trim((string) $route, '/');
if ($route === '' || $this->shouldHideRouteOption($route)) {
continue;
}
$options[] = [
'value' => $route,
'label' => $this->routeLabel($route),
'needs_params' => str_contains($route, '(') || str_contains($route, '[') || str_contains($route, '{'),
];
}
usort($options, static function ($a, $b) {
return strnatcasecmp($a['label'] ?? '', $b['label'] ?? '');
});
return $options;
}
private function shouldHideRouteOption(string $route): bool
{
if (str_starts_with($route, 'api/') || str_starts_with($route, 'docs/') || str_starts_with($route, 'debugbar/')) {
return true;
}
return (bool) preg_match('#(^|/)(csrf-token|file|attachment|download|delete)(/|$)#i', $route);
}
private function routeLabel(string $route): string
{
$label = preg_replace('#\(\?:[^)]+\)|\(\[\^/\]\+\)|\(\[0-9\]\+\)|\(:[a-z_]+\)|\{[^}]+\}#i', '{value}', $route) ?? $route;
$label = str_replace(['_', '-'], ' ', $label);
$label = preg_replace('#/+#', ' / ', $label) ?? $label;
$label = preg_replace('/\s+/', ' ', $label) ?? $label;
return ucwords(trim($label));
}
private function flattenTreeForResponse(array $nodes, array $roleAssignments, ?string $parentLabel = null, int $depth = 0, array &$rows = []): array
{
foreach ($nodes as $node) {
$raw = $node;
unset($raw['children']);
$navId = (int) ($raw['id'] ?? 0);
$roles = $roleAssignments[$navId] ?? ['ids' => [], 'names' => []];
$rows[] = [
'id' => $navId,
'label' => (string) ($raw['label'] ?? ''),
'url' => $raw['url'] ?? null,
'parent_label' => $parentLabel ?? '—',
'parent_id' => isset($raw['menu_parent_id']) && (int) $raw['menu_parent_id'] !== 0
? (int) $raw['menu_parent_id']
: null,
'order' => (int) ($raw['sort_order'] ?? 0),
'enabled' => (int) ($raw['is_enabled'] ?? 0) === 1,
'target' => $raw['target'] ?? null,
'depth' => $depth,
'roles' => [
'ids' => array_values(array_unique($roles['ids'] ?? [])),
'names' => array_values(array_unique($roles['names'] ?? [])),
],
'raw' => $raw,
];
if (!empty($node['children'])) {
$this->flattenTreeForResponse(
$node['children'],
$roleAssignments,
(string) ($raw['label'] ?? ''),
$depth + 1,
$rows
);
}
}
return $rows;
}
private function sortTreeByOrder(array &$nodes): void
{
usort($nodes, function ($a, $b) {
$order = ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0));
if ($order !== 0) {
return $order;
}
$label = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? ''));
if ($label !== 0) {
return $label;
}
return ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
});
foreach ($nodes as &$node) {
if (!empty($node['children'])) {
$this->sortTreeByOrder($node['children']);
}
}
unset($node);
}
}