seedproject-web/api/app/Helpers/Menu.php
Carlos Arias 1559ce017d chore: scaffold SeedProject base (Phase 1)
Clean-room copy of the reusable engines from comiida, with all
instance data, secrets, dependencies, and build output excluded:
- app/         Astro theme skeleton (no comiida blog posts; hero image -> placeholder)
- api/         SeedProject PHP framework (no vendor/.env/config.php)
- content-pipeline/  engine only (scripts/admin/prompts; empty runtime state)
- astroagent.config.json + app/.astroagent/skills

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn
2026-07-04 22:53:10 +00:00

146 lines
5.7 KiB
PHP

<?php
class Menu {
/**
* Resolve a menu item URL by preferred labels within a menu slug.
* Useful for linking static template buttons to Menu Builder-managed URLs.
*/
public static function getItemUrl(string $slug, array $labels, string $fallback = '#'): string {
if (empty($labels)) return $fallback;
$rows = Db::select("
SELECT mi.label, mi.url
FROM sp_menu_items mi
JOIN sp_menus m ON m.menu_id = mi.menu_id
WHERE m.slug = ? AND mi.active = 1 AND m.active = 1
AND mi.type = 'link' AND mi.url IS NOT NULL
", [$slug]);
if (!$rows) return $fallback;
$urlByLabel = [];
foreach ($rows as $row) {
$key = strtolower(trim((string)$row['label']));
$urlByLabel[$key] = (string)$row['url'];
}
foreach ($labels as $label) {
$key = strtolower(trim((string)$label));
if (isset($urlByLabel[$key]) && trim($urlByLabel[$key]) !== '') {
return $urlByLabel[$key];
}
}
return $fallback;
}
/**
* Returns the full nested menu tree for the given slug,
* filtered by the current user's permissions (loaded from their role).
*/
public static function getTree(string $slug): array {
$rows = Db::select("
SELECT mi.*, p.perm_controller, p.perm_action
FROM sp_menu_items mi
JOIN sp_menus m ON m.menu_id = mi.menu_id
LEFT JOIN sp_permissions p ON p.perm_id = mi.perm_id
WHERE m.slug = ? AND mi.active = 1 AND m.active = 1
ORDER BY mi.sort_order ASC
", [$slug]);
if (!$rows) return [];
$isSysAdmin = !empty($_SESSION['login']['sysadmin']);
if (!$isSysAdmin) {
$userPerms = $_SESSION['permissions'] ?? [];
$rows = array_values(array_filter($rows, function($item) use ($userPerms) {
if (!$item['perm_id']) return true;
$key = $item['perm_controller'] . '.' . $item['perm_action'];
return in_array($key, $userPerms);
}));
}
$currentUrl = strtok($_SERVER['REQUEST_URI'], '?');
return self::buildTree($rows, $currentUrl);
}
/**
* Resolves dynamic URL tokens for the logged-in user.
*
* Supported tokens (use in the menu builder URL field):
* %ORG_TOKEN% → encrypted slug of the user's company → /company/%ORG_TOKEN%
* %USER_TOKEN% → encrypted slug of the logged-in user → /account/%USER_TOKEN%/edit
*/
private static function resolveUrlTokens(string $url): string {
if (strpos($url, '%') === false) return $url;
if (strpos($url, '%ORG_TOKEN%') !== false) {
$orgId = $_SESSION['login']['orgid'] ?? 0;
if ($orgId) {
$token = Functions::encryptData((string)$orgId, HASH_PASSWORD_KEY);
$url = str_replace('%ORG_TOKEN%', 'org.' . $token, $url);
}
}
if (strpos($url, '%USER_TOKEN%') !== false) {
$userId = $_SESSION['login']['userid'] ?? 0;
if ($userId) {
$token = Functions::encryptData((string)$userId, HASH_PASSWORD_KEY);
$url = str_replace('%USER_TOKEN%', 'user.' . $token, $url);
}
}
return $url;
}
private static function buildTree(array $rows, string $currentUrl, int $parentId = 0): array {
$branch = [];
foreach ($rows as $row) {
if ((int)$row['parent_id'] === $parentId) {
$row['children'] = self::buildTree($rows, $currentUrl, (int)$row['item_id']);
$rawUrl = $row['url'] ?? '';
$itemUrl = rtrim(self::resolveUrlTokens($rawUrl), '/');
$row['url'] = $itemUrl; // write resolved URL back for the renderer
$curUrl = rtrim($currentUrl, '/');
// URLs containing dynamic tokens (e.g. %ORG_TOKEN%) are encrypted with a
// random IV, so the resolved URL differs on every request and can never
// equal the token already present in the browser's address bar.
// Instead, match on the static path segment that precedes the token.
if (strpos($rawUrl, '%') !== false) {
$staticPrefix = rtrim(preg_replace('/%[^%]+%.*$/', '', $rawUrl), '/');
$row['is_active'] = $staticPrefix !== '' && (
$curUrl === $staticPrefix ||
str_starts_with($curUrl, $staticPrefix . '/')
);
} else {
$row['is_active'] = $itemUrl !== '' && $curUrl === $itemUrl;
}
if (!empty($row['children'])) {
$row['is_open'] = $row['is_active'] || self::hasActiveChild($row['children']);
} elseif ($row['match_prefix'] && $itemUrl !== '') {
$row['is_active'] = str_starts_with($currentUrl, $itemUrl);
$row['is_open'] = $row['is_active'];
} else {
$row['is_open'] = false;
}
// Skip section headers with no visible children
if ($row['type'] === 'header' && empty($row['children'])) continue;
$branch[] = $row;
}
}
return $branch;
}
private static function hasActiveChild(array $children): bool {
foreach ($children as $child) {
if ($child['is_active'] || $child['is_open']) return true;
}
return false;
}
}