add projet
This commit is contained in:
+377
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\NavItem;
|
||||
use App\Models\RoleNavItem;
|
||||
use App\Models\Role;
|
||||
use App\Services\NavbarService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class NavBuilderController extends BaseApiController
|
||||
{
|
||||
protected NavItem $items;
|
||||
protected RoleNavItem $maps;
|
||||
protected NavbarService $service;
|
||||
protected Role $role;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user