items = model(NavItem::class); $this->maps = model(RoleNavItem::class); $this->service = app(NavbarService::class); $this->role = model(Role::class); } /** * Check if user has admin access to nav-builder */ protected function ensureAdmin(): void { $user = $this->getCurrentUser(); if (!$user) { throw new \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException('', 'Authentication required'); } $roleNames = $user->roles ?? []; if (empty($roleNames)) { throw new \Symfony\Component\HttpKernel\Exception\ForbiddenHttpException('Access denied'); } // Map role names -> ids $roleIdRows = DB::table('roles') ->select('id') ->whereIn('name', $roleNames) ->get() ->toArray(); $roleIds = array_map('intval', array_column($roleIdRows, 'id')); if (empty($roleIds)) { throw new \Symfony\Component\HttpKernel\Exception\ForbiddenHttpException('Access denied'); } // Is this route allowed for any of the user's roles? $allowed = DB::table('role_nav_items AS rni') ->select('1') ->join('nav_items AS ni', 'ni.id', '=', 'rni.nav_item_id') ->where('ni.url', 'nav-builder') ->whereIn('rni.role_id', $roleIds) ->first(); if (!$allowed) { throw new \Symfony\Component\HttpKernel\Exception\ForbiddenHttpException('Access denied'); } } /** * GET /api/v1/nav-builder/data * Get navigation builder data (items, roles, parent options) */ public function data() { try { $this->ensureAdmin(); $payload = $this->buildNavPayload(); return $this->success($payload, 'Navigation data retrieved successfully'); } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { return $this->respondError($e->getMessage(), $e->getStatusCode()); } catch (\Throwable $e) { log_message('error', 'Nav builder data error: ' . $e->getMessage()); return $this->respondError('Failed to retrieve navigation data', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * POST /api/v1/nav-builder * Save or update a navigation item */ public function save() { try { $this->ensureAdmin(); $payload = $this->payloadData(); $id = (int) ($payload['id'] ?? 0); // Accept either "menu_parent_id" or "parent_id" from the form $menuParentRaw = $payload['menu_parent_id'] ?? $payload['parent_id'] ?? null; $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->find($menuParentId); if (!$parent) { return $this->respondError('Selected parent does not exist.', Response::HTTP_BAD_REQUEST); } } $data = [ 'menu_parent_id' => $menuParentId, 'label' => trim((string) ($payload['label'] ?? '')), 'url' => trim((string) ($payload['url'] ?? '')) ?: null, 'icon_class' => trim((string) ($payload['icon_class'] ?? '')) ?: null, 'target' => trim((string) ($payload['target'] ?? '')) ?: null, 'sort_order' => (int) ($payload['sort_order'] ?? 0), 'is_enabled' => (int) (($payload['is_enabled'] ?? false) ? 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) ($payload['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(); return $this->success([ 'id' => $id, 'message' => 'Menu saved successfully', ], 'Menu saved successfully'); } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { return $this->respondError($e->getMessage(), $e->getStatusCode()); } catch (\Throwable $e) { log_message('error', 'Nav builder save error: ' . $e->getMessage()); return $this->respondError('Failed to save menu item: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * DELETE /api/v1/nav-builder/{id} * Delete a navigation item */ public function delete($id) { try { $this->ensureAdmin(); $id = (int) $id; $this->items->delete($id); // children handled by FK (SET NULL on parent) $this->service->clearCache(); return $this->success([ 'id' => $id, 'message' => 'Menu item deleted successfully', ], 'Menu item deleted successfully'); } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { return $this->respondError($e->getMessage(), $e->getStatusCode()); } catch (\Throwable $e) { log_message('error', 'Nav builder delete error: ' . $e->getMessage()); return $this->respondError('Failed to delete menu item: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * POST /api/v1/nav-builder/reorder * Reorder navigation items */ public function reorder() { try { $this->ensureAdmin(); $payload = $this->payloadData(); $orders = $payload['orders'] ?? []; foreach ($orders as $id => $order) { $this->items->update((int) $id, ['sort_order' => (int) $order]); } $this->service->clearCache(); return $this->success(['ok' => true], 'Navigation items reordered successfully'); } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { return $this->respondError($e->getMessage(), $e->getStatusCode()); } catch (\Throwable $e) { log_message('error', 'Nav builder reorder error: ' . $e->getMessage()); return $this->respondError('Failed to reorder navigation items: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Get distinct roles */ protected function distinctRoles(): array { return DB::table('roles') ->select('id', 'name') ->orderBy('name') ->get() ->toArray(); } /** * Build navigation payload */ 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, ]; } /** * 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'); } /** * Sort tree alphabetically */ 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); } /** * Flatten tree for response */ 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; } }