Skip to content

feat(admin): add menu registry and search infrastructure#10

Merged
Snider merged 6 commits intomainfrom
dev
Apr 27, 2026
Merged

feat(admin): add menu registry and search infrastructure#10
Snider merged 6 commits intomainfrom
dev

Conversation

@Snider
Copy link
Copy Markdown
Contributor

@Snider Snider commented Apr 27, 2026

Routine dev→main PR opened by Hephaestus PR-cadence task.

This PR exists to:

  1. Trigger CodeRabbit auto-review of accumulated dev work
  2. Surface any blocking review feedback before merge
  3. Keep main current with dev once approved

ahead_by: 3 commit(s) (per gh api compare)

If CodeRabbit clears with no blocking comments, this PR is eligible for gh pr merge --merge (real merge commit, no force-push). Conflicts and review feedback should be addressed on the dev branch before merge.

Co-authored-by: Hephaestus hephaestus@cladius

Summary by CodeRabbit

  • New Features

    • Provider-based admin menu system with grouping, lazy routes and deterministic ordering
    • User and workspace search providers plus an aggregated search dispatcher with relevance sorting
    • Hub route helpers and multi-domain route handling for consistent admin URL resolution
  • Tests

    • Comprehensive feature tests for menu and search subsystems; Pest test entrypoint added
  • Chores

    • Development tooling and build config updated (Composer, Node/PostCSS/Tailwind adjustments)

Snider and others added 3 commits April 23, 2026 17:58
Treat the first resolved domain as canonical (keeps the `hub.` name
prefix); secondary-domain registrations get routed through a
prefixSecondaryDomainRoutes() helper that renames only newly-added
routes with a domain-qualified slug prefix. Clears the remaining 25
hub.* collisions that originate in this vendored package — the pattern
mirrors what already landed for app.*, api.docs.*, lthn.*, mcp.* in
lthn.ai (commit 0de9ad9).

Closes tasks.lthn.sh/view.php?id=88

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Virgil <virgil@lethean.io>
…er + 2 providers) (#855)

- SearchProvider interface (search/getLabel/getPriority)
- SearchResult DTO (title/subtitle/url/icon/category/score) — readonly with legacy registry compatibility preserved
- SearchDispatcher — register providers, gather, sort by score desc
- UserSearchProvider, WorkspaceSearchProvider as built-ins
- Pest Feature tests _Good/_Bad/_Ugly per AX-10
- pint/pest skipped (vendor binaries missing in sandbox)

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=855
…act (#854)

- AdminMenuProvider interface (getMenuItems/getMenuGroups/getPriority)
- MenuItem + MenuGroup readonly DTOs
- AdminMenuRegistry: provider registration, RFC default groups, custom
  group merging, sorted grouped resolution, flat items()
- src/Boot.php registers the registry singleton
- Pest Feature tests _Good/_Bad/_Ugly per AX-10 (replaces legacy
  menu-system tests that tested the prior implementation)
- pint/pest skipped (vendor binaries missing in sandbox)

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=854
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

Warning

Rate limit exceeded

@Snider has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 6 minutes and 39 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7bbe6c7b-7b6c-402d-853e-67e3946644ff

📥 Commits

Reviewing files that changed from the base of the PR and between 48b764a and 0edd921.

📒 Files selected for processing (3)
  • src/Search/Providers/WorkspaceSearchProvider.php
  • src/Search/SearchResult.php
  • tests/Feature/Search/SearchSystemTest.php
📝 Walkthrough

Walkthrough

Adds a new admin menu subsystem (provider interface, registry, MenuGroup/MenuItem models, and service-provider binding) and a search subsystem (provider interface, dispatcher, SearchResult refactor, concrete user/workspace providers, and LIKE-term helper). Also changes Hub route handling to prefix secondary-domain route names and updates many Blade/views to use new hubRoute helpers; adds tests and build/tooling updates.

Changes

Cohort / File(s) Summary
Service provider registration
src/Boot.php
Registers \Core\Admin\Menu\AdminMenuRegistry as a singleton in the container during provider register().
Admin menu API & models
src/Menu/AdminMenuProvider.php, src/Menu/AdminMenuRegistry.php, src/Menu/MenuGroup.php, src/Menu/MenuItem.php
New AdminMenuProvider interface and AdminMenuRegistry (register/registerMany/providers/groups/resolve/items). Immutable MenuGroup and MenuItem value objects with fromArray() factories, normalization, validation and deterministic ordering logic; registry normalises closures/arrays and throws InvalidArgumentException on invalid inputs.
Search core & helpers
src/Search/SearchProvider.php, src/Search/SearchDispatcher.php, src/Search/SearchResult.php, src/Search/Concerns/BuildsLikeTerms.php
Adds SearchProvider interface, SearchDispatcher (provider registration, aggregation, try/catch per provider, stable sorting), refactors SearchResult to variadic constructor with readonly category/score, and adds BuildsLikeTerms trait for escaped SQL LIKE patterns.
Concrete search providers
src/Search/Providers/UserSearchProvider.php, src/Search/Providers/WorkspaceSearchProvider.php
New providers performing escaped LIKE Eloquent queries, mapping models to SearchResult, computing relevance scores, enforcing candidate/return limits, and exposing labels/priorities.
Hub routing & URL helpers
src/Website/Hub/Boot.php
Refactors domain route registration to treat first domain as primary and prefix secondary-domain route names; adds hubRoute, hubRouteName, hubRouteIs and private helper methods for prefixing.
Blade/views & Livewire components
src/Website/Hub/View/Blade/..., src/Website/Hub/View/Modal/Admin/... (many files)
Replaces numerous route(...) usages with \Website\Hub\Boot::hubRoute(...) and updates imports/livewire registrations so URLs and active checks respect domain-prefixed route names.
Tests (menu & search)
tests/Feature/Menu/AdminMenuSystemTest.php, tests/Feature/Search/SearchSystemTest.php, tests/Pest.php
Rewrites menu tests to target AdminMenuRegistry::resolve() (grouped structure, ordering, lazy routes, validation). Adds comprehensive search tests with in-memory SQLite setup and Pest bootstrap.
Build / tooling / config
composer.json, package.json, postcss.config.js, .github/workflows/ci.yml
Pins/adjusts Composer deps and adds dev tools (pest, pint), adds @tailwindcss/postcss, updates PostCSS plugin, and bumps actions/setup-node to v6 with Node 24 in CI.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant Registry as AdminMenuRegistry
    participant Provider as AdminMenuProvider[]
    participant Group as MenuGroup[]
    participant Item as MenuItem[]
    Client->>Registry: request resolved menu
    Registry->>Provider: iterate registered providers (by priority+index)
    Provider-->>Registry: return getMenuGroups(), getMenuItems()
    Registry->>Group: normalise/merge provider groups with defaults
    Group-->>Registry: MenuGroup instances
    Registry->>Item: normalise items, ensure group exists
    Item-->>Registry: MenuItem instances
    Registry->>Registry: sort by composite key (groupPrio,itemPrio,providerPrio,indices,label)
    Registry-->>Client: grouped, deterministically-ordered menu structure
Loading
sequenceDiagram
    actor User
    participant Dispatcher as SearchDispatcher
    participant Provider as SearchProvider[]
    participant DB as Database/Eloquent
    participant Results as SearchResult[]
    User->>Dispatcher: search(query)
    alt query blank
        Dispatcher-->>User: return []
    else
        Dispatcher->>Provider: call search(query) (try/catch per provider)
        loop per provider
            Provider->>DB: perform LIKE queries (escaped)
            DB-->>Provider: model rows
            Provider-->>Dispatcher: mapped SearchResult[] (with score)
        end
        Dispatcher->>Results: annotate results with provider priority + index
        Results->>Results: sort by score desc, provider priority desc, index asc
        Results-->>Dispatcher: ordered results
        Dispatcher-->>User: return ordered SearchResult[]
    end
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarises the main changes: addition of admin menu registry and search infrastructure components.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@Snider
Copy link
Copy Markdown
Contributor Author

Snider commented Apr 27, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (3)
src/Website/Hub/Boot.php (1)

135-138: Use a collision-safe domain prefix for route names

Line 137 maps both . and - to _, which is not injective (e.g. a-b.c and a.b-c both collapse to a_b_c). That can reintroduce secondary-route name collisions.

Proposed hardening diff
 private static function domainRoutePrefix(string $domain): string
 {
-    return strtr($domain, ['.' => '_', '-' => '_']).'.';
+    $normalised = strtolower($domain);
+    $slug = preg_replace('/[^a-z0-9]+/', '_', $normalised) ?? 'domain';
+    $hash = substr(hash('sha256', $normalised), 0, 8);
+
+    return $slug.'_'.$hash.'.';
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Website/Hub/Boot.php` around lines 135 - 138, The current
domainRoutePrefix in function domainRoutePrefix maps both '.' and '-' to '_' and
is not injective; change it to produce an injective, collision-safe prefix (e.g.
replace the simple char mapping with a reversible encoding of the domain such as
a URL-safe base64 or hex encoding) so different domains always produce different
prefixes; update domainRoutePrefix to encode the full $domain with that
reversible encoding and then append the trailing dot as before (use the
domainRoutePrefix function name to locate the code and replace the strtr logic).
src/Menu/AdminMenuProvider.php (1)

22-34: Sync the phpdoc with the supported payload shapes.

AdminMenuRegistry and the new tests accept raw array items/groups and lazy wrappers, but this contract only advertises MenuItem[] / MenuGroup[]. Either narrow the registry or widen the phpdoc here so module authors do not code against the wrong public API.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Menu/AdminMenuProvider.php` around lines 22 - 34, The phpdoc for
getMenuItems() and getMenuGroups() is too narrow: update their `@return`
annotations to reflect that implementations may return concrete
MenuItem/MenuGroup instances, raw array payloads, or lazy wrappers/callables
accepted by AdminMenuRegistry and the tests; e.g. change `@return` array<int,
MenuItem> to `@return` array<int, MenuItem|array|callable> for getMenuItems() and
similarly change `@return` array<int, MenuGroup> to `@return` array<int,
MenuGroup|array|callable> for getMenuGroups() so module authors see the actual
supported payload shapes.
src/Search/SearchResult.php (1)

117-145: Minor: Consider edge case where legacy subtitle is purely numeric.

The heuristic is_numeric($arguments[5]) distinguishes between the new DTO shape (where position 5 is score) and the legacy shape (where position 5 is subtitle). This works correctly for typical subtitles like email addresses, but a purely numeric subtitle (e.g., '12345') would be misinterpreted as a score.

Given that subtitles are typically descriptive strings, this is unlikely to occur in practice. However, if stricter detection is desired, you could check additional positions (e.g., whether $arguments[4] looks like an icon class vs. a category string).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Search/SearchResult.php` around lines 117 - 145, The is_numeric check in
normaliseConstructorArguments misclassifies a purely numeric legacy subtitle at
$arguments[5] as a score; update the conditional that detects the new DTO shape
so it requires both that $arguments[5] is numeric AND that $arguments[4] also
looks like the new-shape category (e.g. validate $arguments[4] is a non-empty
category-like string and not an icon/class token), or otherwise treat it as the
legacy shape — change the if that inspects $arguments[5] to include this extra
$arguments[4] validation to avoid misinterpreting numeric subtitles.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Menu/MenuGroup.php`:
- Around line 23-50: The constructor and fromArray currently only trim for
validation but store $key and $label with original whitespace; update the code
so the stored properties are trimmed: in the __construct (or just before
assignment) ensure $this->key and $this->label are the trimmed values (e.g. use
trim($key) and trim($label)), and adjust fromArray to pass trimmed values
(compute $key = trim((string)($data['key'] ?? '')) and label =
trim((string)($data['label'] ?? self::labelFromKey($key)))) so labelFromKey
receives the cleaned key and whitespace-only inputs are normalized.

In `@src/Search/Providers/UserSearchProvider.php`:
- Around line 44-52: The current query applies limit($this->limit) before
computing scores, which can drop better matches; remove the DB-level limit and
instead fetch all matching models (or a safely large cap), map them via
resultFor($user, $query) to SearchResult objects, then sort the resulting array
by the SearchResult->score() (or the comparator used downstream) and finally
slice the top $this->limit items before returning; update both occurrences
around the query (the shown block and the similar block at lines ~86-100) to use
this fetch-then-rank-then-limit approach so ranking is applied to the full
candidate set.
- Around line 44-48: The code in UserSearchProvider that escapes '%' and '_' by
prefixing with backslashes is not portable across SQL drivers; instead, build a
properly escaped search pattern and use an explicit ESCAPE clause so all
backends treat your escape character consistently. Concretely: when constructing
$term, replace '%' and '_' with an escaped form using a chosen escape char (e.g.
backslash), then use a raw where with parameter binding like "name LIKE ? ESCAPE
'\\'" (and same for email) so you pass the escaped pattern as a bound parameter;
update the closure in the query (the Builder lambda in UserSearchProvider) and
the similar block referenced at lines 81-84 to use this ESCAPE-aware where
clause for both name and email checks.

In `@src/Search/Providers/WorkspaceSearchProvider.php`:
- Around line 44-52: The query currently calls limit() before applying scoring,
so the database trims results prior to ranking; update the search flow in
WorkspaceSearchProvider so you apply score() (or otherwise compute/order by the
relevance score) before calling limit(), then call limit($this->limit) and only
after that ->get()->map(fn(Model $workspace) => $this->resultFor($workspace,
$query))->all(); ensure the same change is applied to the other occurrence that
mirrors lines 86-100 so resultFor is only run on the top-ranked items.
- Around line 44-48: Replace raw "like" usage that interpolates $term in
WorkspaceSearchProvider (the anonymous closure passed to
$modelClass::query()->where(...) and the similar block around lines 81-84) with
a portable escaped LIKE helper: call the project's likeTerm() utility (or
implement an escapeAndWrapLike($term) helper) to escape SQL wildcard characters
and pass the returned pattern to ->where('name', 'like', $escapedTerm) /
->orWhere('slug', 'like', $escapedTerm); ensure the helper accounts for
driver-specific ESCAPE behaviour (adds an explicit ESCAPE clause or driver-aware
escaping) so both the closure in the method using Builder $builder and the other
similar search block use the same escaped pattern helper.

In `@src/Search/SearchDispatcher.php`:
- Around line 62-74: The loop in SearchDispatcher that iterates $this->providers
and calls $provider->search($query) can let a thrown exception from any provider
abort aggregation; wrap the call to $provider->search($query) and its inner
iteration in a try/catch so a thrown exception from a single provider is caught,
reported (e.g. via a logger or by adding an error entry to results) and the loop
continues to the next provider; ensure you still filter by instanceof
SearchResult and preserve priority/index behavior when collecting into $ranked
so healthy providers' results are not lost.

In `@src/Website/Hub/Boot.php`:
- Around line 108-110: The secondary-domain renaming makes hardcoded route
lookups (route('hub.*') and request()->routeIs('hub.*')) in adminMenuItems() and
view files (e.g., SiteSettings.php) fail on secondary domains; update the code
to resolve both canonical and prefixed names: add a small domain-aware resolver
(e.g., hubRouteName($name) / hubRouteIs($pattern)) that checks the unprefixed
name first then the prefixed name produced by prefixSecondaryDomainRoutes(), and
replace direct calls to route('hub.*') and request()->routeIs('hub.*') in
adminMenuItems() and view closures with these helpers (or alternatively
implement a primary-domain redirect earlier in Boot.php before rendering).
Ensure references to prefixSecondaryDomainRoutes and adminMenuItems are used to
form the prefixed name so the helper covers all renamed routes.

---

Nitpick comments:
In `@src/Menu/AdminMenuProvider.php`:
- Around line 22-34: The phpdoc for getMenuItems() and getMenuGroups() is too
narrow: update their `@return` annotations to reflect that implementations may
return concrete MenuItem/MenuGroup instances, raw array payloads, or lazy
wrappers/callables accepted by AdminMenuRegistry and the tests; e.g. change
`@return` array<int, MenuItem> to `@return` array<int, MenuItem|array|callable> for
getMenuItems() and similarly change `@return` array<int, MenuGroup> to `@return`
array<int, MenuGroup|array|callable> for getMenuGroups() so module authors see
the actual supported payload shapes.

In `@src/Search/SearchResult.php`:
- Around line 117-145: The is_numeric check in normaliseConstructorArguments
misclassifies a purely numeric legacy subtitle at $arguments[5] as a score;
update the conditional that detects the new DTO shape so it requires both that
$arguments[5] is numeric AND that $arguments[4] also looks like the new-shape
category (e.g. validate $arguments[4] is a non-empty category-like string and
not an icon/class token), or otherwise treat it as the legacy shape — change the
if that inspects $arguments[5] to include this extra $arguments[4] validation to
avoid misinterpreting numeric subtitles.

In `@src/Website/Hub/Boot.php`:
- Around line 135-138: The current domainRoutePrefix in function
domainRoutePrefix maps both '.' and '-' to '_' and is not injective; change it
to produce an injective, collision-safe prefix (e.g. replace the simple char
mapping with a reversible encoding of the domain such as a URL-safe base64 or
hex encoding) so different domains always produce different prefixes; update
domainRoutePrefix to encode the full $domain with that reversible encoding and
then append the trailing dot as before (use the domainRoutePrefix function name
to locate the code and replace the strtr logic).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 46f6b3c2-b89c-430a-b203-95ee67c1560e

📥 Commits

Reviewing files that changed from the base of the PR and between 8ed3023 and cf88f31.

📒 Files selected for processing (13)
  • src/Boot.php
  • src/Menu/AdminMenuProvider.php
  • src/Menu/AdminMenuRegistry.php
  • src/Menu/MenuGroup.php
  • src/Menu/MenuItem.php
  • src/Search/Providers/UserSearchProvider.php
  • src/Search/Providers/WorkspaceSearchProvider.php
  • src/Search/SearchDispatcher.php
  • src/Search/SearchProvider.php
  • src/Search/SearchResult.php
  • src/Website/Hub/Boot.php
  • tests/Feature/Menu/AdminMenuSystemTest.php
  • tests/Feature/Search/SearchSystemTest.php

Comment thread src/Menu/MenuGroup.php Outdated
Comment thread src/Search/Providers/UserSearchProvider.php
Comment thread src/Search/Providers/UserSearchProvider.php
Comment thread src/Search/Providers/WorkspaceSearchProvider.php
Comment thread src/Search/Providers/WorkspaceSearchProvider.php
Comment thread src/Search/SearchDispatcher.php
Comment thread src/Website/Hub/Boot.php
@Snider Snider changed the title chore(dev→main): routine PR cadence for CodeRabbit review feat(admin): add menu registry and search infrastructure Apr 27, 2026
10 CodeRabbit findings + pre-merge checks + CI annotations dispositioned.

Code:
- MenuGroup: trims stored key/label
- User/workspace search: explicit LIKE ... ESCAPE '!' (was unsafe);
  ranks all candidates before provider-limit application
- SearchDispatcher: reports provider failures best-effort + continues
  (was failing fast on first provider error)
- Hub routes: domain-aware helpers across Hub PHP/Blade files
- AdminMenuProvider: PHPDoc widened
- SearchResult: no longer misclassifies numeric legacy subtitles
- Secondary-domain route prefixes: collision-safe via hex encoding

Doc / metadata:
- PHPDoc @example blocks added across touched PHP APIs
- composer.json: laravel/pint + pestphp/pest dev tooling + plugin config
- package.json + package-lock.json: Tailwind 4 PostCSS via
  @tailwindcss/postcss
- .github/workflows/ci.yml: actions/setup-node v6 + Node 24

Disposition replies:
- GHAS/SonarCloud: no exposed findings via PR comments / check
  annotations / API; Dependabot 0 open alerts; secret scanning
  disabled. RESOLVED-COMMENT.

Verification: php -l clean, composer validate --strict clean,
pint --dirty + pint --test --dirty pass, pest on touched test files
26 tests / 83 assertions pass. npm ci + npm run build pass.

Pre-existing test-env failure noted (out of scope): vendor/lthn/php
references missing Core\Front\Client\Boot — affects unrelated
app-booted tests + one SearchProviderRegistry expectation. Tracked
in php repo's pending Service-module work.

Closes findings on #10

Co-authored-by: Codex <noreply@openai.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/Search/Providers/WorkspaceSearchProvider.php (1)

113-116: Consider extracting shared likeTerm() to a trait or base class.

Both UserSearchProvider and WorkspaceSearchProvider have identical likeTerm() implementations. If more providers follow this pattern, consider extracting to a shared concern.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Search/Providers/WorkspaceSearchProvider.php` around lines 113 - 116,
Both WorkspaceSearchProvider and UserSearchProvider duplicate the likeTerm()
logic; extract this shared behavior into a single reusable unit (e.g., a trait
LikeTermTrait with a protected function likeTerm(string $query): string) and
have both classes use that trait (or alternatively create a common abstract base
class with likeTerm and have both providers extend it), then remove the
duplicate likeTerm() methods from WorkspaceSearchProvider and UserSearchProvider
so they use the centralized implementation.
src/Search/Providers/UserSearchProvider.php (1)

50-55: Consider adding a safety cap on unbounded queries.

Fetching all matching records before applying the limit could be problematic for large user tables with common search terms. Consider adding a generous but bounded ->limit(1000) (or configurable cap) at the database level as a safety net, whilst still preserving the rank-then-trim behaviour.

💡 Optional safeguard
 return $modelClass::query()
     ->where(function (Builder $builder) use ($term): void {
         $builder->whereRaw("name LIKE ? ESCAPE '!'", [$term])
             ->orWhereRaw("email LIKE ? ESCAPE '!'", [$term]);
     })
+    ->limit(1000) // Safety cap before in-memory ranking
     ->get()
     ->map(fn (Model $user): SearchResult => $this->resultFor($user, $query))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Search/Providers/UserSearchProvider.php` around lines 50 - 55, The query
in UserSearchProvider that returns $modelClass::query()->where(...)->get() is
unbounded and can load too many rows; add a database-level safety cap (e.g.
->limit(1000)) before ->get() to bound results and make the cap configurable
(inject a config value or class property) so it can be tuned, while still
performing your existing rank-and-trim step afterwards; update the query chain
where $modelClass::query() is used in UserSearchProvider to include
->limit($this->searchLimit ?? config('search.user_limit', 1000)) before calling
->get().
src/Search/SearchResult.php (1)

66-79: Consider delegating to the constructor to reduce duplication.

The fromArray() method duplicates the normalisation logic that the constructor also performs. Since the constructor already handles named arguments, fromArray() could delegate directly.

💡 Potential simplification
 public static function fromArray(array $data): static
 {
-    return new self(
-        id: (string) ($data['id'] ?? uniqid('', true)),
-        title: (string) ($data['title'] ?? ''),
-        url: (string) ($data['url'] ?? '#'),
-        type: (string) ($data['type'] ?? $data['category'] ?? 'unknown'),
-        icon: (string) ($data['icon'] ?? 'document'),
-        subtitle: $data['subtitle'] ?? null,
-        meta: $data['meta'] ?? [],
-        category: (string) ($data['category'] ?? $data['type'] ?? 'unknown'),
-        score: (int) ($data['score'] ?? 0),
-    );
+    return new self(...$data);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Search/SearchResult.php` around lines 66 - 79, The fromArray method
duplicates constructor normalization; replace the manual field-by-field
construction by building an associative $args array whose keys match the
constructor parameter names (id, title, url, type, icon, subtitle, meta,
category, score), apply only minimal defaults/casts there, and then delegate to
the constructor via new self(...$args) (i.e. call new self with the unpacked
associative array) so all normalization lives in the constructor
(functions/methods referenced: fromArray, __construct).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/Search/Providers/UserSearchProvider.php`:
- Around line 50-55: The query in UserSearchProvider that returns
$modelClass::query()->where(...)->get() is unbounded and can load too many rows;
add a database-level safety cap (e.g. ->limit(1000)) before ->get() to bound
results and make the cap configurable (inject a config value or class property)
so it can be tuned, while still performing your existing rank-and-trim step
afterwards; update the query chain where $modelClass::query() is used in
UserSearchProvider to include ->limit($this->searchLimit ??
config('search.user_limit', 1000)) before calling ->get().

In `@src/Search/Providers/WorkspaceSearchProvider.php`:
- Around line 113-116: Both WorkspaceSearchProvider and UserSearchProvider
duplicate the likeTerm() logic; extract this shared behavior into a single
reusable unit (e.g., a trait LikeTermTrait with a protected function
likeTerm(string $query): string) and have both classes use that trait (or
alternatively create a common abstract base class with likeTerm and have both
providers extend it), then remove the duplicate likeTerm() methods from
WorkspaceSearchProvider and UserSearchProvider so they use the centralized
implementation.

In `@src/Search/SearchResult.php`:
- Around line 66-79: The fromArray method duplicates constructor normalization;
replace the manual field-by-field construction by building an associative $args
array whose keys match the constructor parameter names (id, title, url, type,
icon, subtitle, meta, category, score), apply only minimal defaults/casts there,
and then delegate to the constructor via new self(...$args) (i.e. call new self
with the unpacked associative array) so all normalization lives in the
constructor (functions/methods referenced: fromArray, __construct).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 58fa76bb-0bc9-4fc3-90da-ecb41b581a06

📥 Commits

Reviewing files that changed from the base of the PR and between cf88f31 and 9e19d67.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (36)
  • .github/workflows/ci.yml
  • composer.json
  • package.json
  • postcss.config.js
  • src/Menu/AdminMenuProvider.php
  • src/Menu/AdminMenuRegistry.php
  • src/Menu/MenuGroup.php
  • src/Menu/MenuItem.php
  • src/Search/Providers/UserSearchProvider.php
  • src/Search/Providers/WorkspaceSearchProvider.php
  • src/Search/SearchDispatcher.php
  • src/Search/SearchProvider.php
  • src/Search/SearchResult.php
  • src/Website/Hub/Boot.php
  • src/Website/Hub/View/Blade/admin/account-usage.blade.php
  • src/Website/Hub/View/Blade/admin/boost-purchase.blade.php
  • src/Website/Hub/View/Blade/admin/components/header.blade.php
  • src/Website/Hub/View/Blade/admin/components/sidebar.blade.php
  • src/Website/Hub/View/Blade/admin/content-editor.blade.php
  • src/Website/Hub/View/Blade/admin/content-manager.blade.php
  • src/Website/Hub/View/Blade/admin/content-manager/list.blade.php
  • src/Website/Hub/View/Blade/admin/content.blade.php
  • src/Website/Hub/View/Blade/admin/dashboard.blade.php
  • src/Website/Hub/View/Blade/admin/platform-user.blade.php
  • src/Website/Hub/View/Blade/admin/platform.blade.php
  • src/Website/Hub/View/Blade/admin/profile.blade.php
  • src/Website/Hub/View/Blade/admin/services-admin.blade.php
  • src/Website/Hub/View/Blade/admin/sites.blade.php
  • src/Website/Hub/View/Modal/Admin/ContentManager.php
  • src/Website/Hub/View/Modal/Admin/PlatformUser.php
  • src/Website/Hub/View/Modal/Admin/SiteSettings.php
  • src/Website/Hub/View/Modal/Admin/Sites.php
  • src/Website/Hub/View/Modal/Admin/WorkspaceSwitcher.php
  • tests/Feature/Menu/AdminMenuSystemTest.php
  • tests/Feature/Search/SearchSystemTest.php
  • tests/Pest.php
✅ Files skipped from review due to trivial changes (8)
  • package.json
  • src/Website/Hub/View/Blade/admin/components/sidebar.blade.php
  • src/Website/Hub/View/Blade/admin/content-editor.blade.php
  • src/Website/Hub/View/Blade/admin/boost-purchase.blade.php
  • src/Search/SearchProvider.php
  • tests/Pest.php
  • src/Menu/AdminMenuProvider.php
  • src/Website/Hub/View/Blade/admin/platform-user.blade.php
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/Menu/MenuGroup.php
  • tests/Feature/Search/SearchSystemTest.php

…Result normalisation on PR #10

Round 2 follow-up to 9e19d67. Closes residual CodeRabbit findings.

Code:
- src/Search/Providers/UserSearchProvider.php: configurable user
  candidate cap before ranking (default 1000) — was unbounded
- src/Search/Concerns/BuildsLikeTerms.php (new): shared LIKE pattern
  escaping trait used by both user + workspace providers
- src/Search/Providers/WorkspaceSearchProvider.php: uses BuildsLikeTerms
- src/Search/SearchResult.php: fromArray() delegates normalisation
  through constructor (was duplicating logic)

Tests:
- tests/Feature/Search/SearchSystemTest.php: 'Ugly' test for the
  configurable cap path (cap-before-ranking behaviour verified)

Verification: php -l clean, pint --dirty + pint --test --dirty pass,
pest on touched test files pass (20 + 7 = 27 tests).

Closes residual findings on #10

Co-authored-by: Codex <noreply@openai.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/Search/Providers/WorkspaceSearchProvider.php (1)

53-63: Bound candidates before in-memory ranking to avoid unbounded loads.

Line 58 currently fetches all matching workspaces before scoring and slicing. On large datasets, this can create avoidable memory and latency spikes. Consider adding a bounded candidate cap (same pattern as UserSearchProvider) before get().

💡 Suggested refactor
 public function __construct(
     private readonly string $modelClass = Workspace::class,
-    private readonly int $limit = 10
+    private readonly int $limit = 10,
+    private readonly int $candidateLimit = 1000
 ) {}
@@
 return $modelClass::query()
     ->where(function (Builder $builder) use ($term): void {
         $builder->whereRaw("name LIKE ? ESCAPE '!'", [$term])
             ->orWhereRaw("slug LIKE ? ESCAPE '!'", [$term]);
     })
+    ->limit($this->candidateLimit())
     ->get()
     ->map(fn (Model $workspace): SearchResult => $this->resultFor($workspace, $query))
     ->sortByDesc(static fn (SearchResult $result): int => $result->score)
     ->take($this->limit)
     ->values()
     ->all();
+
+private function candidateLimit(): int
+{
+    return max($this->limit, $this->candidateLimit, 1);
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Search/Providers/WorkspaceSearchProvider.php` around lines 53 - 63, The
query in WorkspaceSearchProvider currently calls get() and then scores/slices,
leading to unbounded loads; modify the query built from $modelClass::query() to
apply a bounded candidate cap (use the same pattern as UserSearchProvider)
before calling get() so you fetch at most a safe number of candidates (e.g.,
based on $this->limit or a multiplier) and then perform map($this->resultFor) /
sortByDesc / take($this->limit) in-memory; update the code around the anonymous
where closure and the subsequent get() call to include that pre-get limit.
src/Search/SearchResult.php (1)

105-120: Keep serialisation symmetric with the expanded DTO fields.

Lines 112-120 omit category and score, even though the DTO now carries both and fromArray() accepts both. Including them avoids silent data loss during array/JSON round-trips.

💡 Suggested refactor
- * `@return` array{id: string, title: string, subtitle: ?string, url: string, type: string, icon: string, meta: array}
+ * `@return` array{id: string, title: string, subtitle: ?string, url: string, type: string, icon: string, category: string, score: int, meta: array}
@@
         return [
             'id' => $this->id,
             'title' => $this->title,
             'subtitle' => $this->subtitle,
             'url' => $this->url,
             'type' => $this->type,
             'icon' => $this->icon,
+            'category' => $this->category,
+            'score' => $this->score,
             'meta' => $this->meta,
         ];

Also applies to: 126-133

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Search/SearchResult.php` around lines 105 - 120, The toArray() method in
SearchResult is missing the category and score fields, causing asymmetric
serialization with fromArray(); update SearchResult::toArray() to include
'category' => $this->category and 'score' => $this->score so array/JSON
round-trips preserve all DTO fields and match SearchResult::fromArray().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/Search/Providers/WorkspaceSearchProvider.php`:
- Around line 53-63: The query in WorkspaceSearchProvider currently calls get()
and then scores/slices, leading to unbounded loads; modify the query built from
$modelClass::query() to apply a bounded candidate cap (use the same pattern as
UserSearchProvider) before calling get() so you fetch at most a safe number of
candidates (e.g., based on $this->limit or a multiplier) and then perform
map($this->resultFor) / sortByDesc / take($this->limit) in-memory; update the
code around the anonymous where closure and the subsequent get() call to include
that pre-get limit.

In `@src/Search/SearchResult.php`:
- Around line 105-120: The toArray() method in SearchResult is missing the
category and score fields, causing asymmetric serialization with fromArray();
update SearchResult::toArray() to include 'category' => $this->category and
'score' => $this->score so array/JSON round-trips preserve all DTO fields and
match SearchResult::fromArray().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: afb15f0a-0873-43ed-be8e-cf24fe8e3825

📥 Commits

Reviewing files that changed from the base of the PR and between 9e19d67 and 48b764a.

📒 Files selected for processing (5)
  • src/Search/Concerns/BuildsLikeTerms.php
  • src/Search/Providers/UserSearchProvider.php
  • src/Search/Providers/WorkspaceSearchProvider.php
  • src/Search/SearchResult.php
  • tests/Feature/Search/SearchSystemTest.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/Feature/Search/SearchSystemTest.php

… completeness on PR #10

Round 3 follow-up to 48b764a.

Code:
- WorkspaceSearchProvider.php: configurable candidateLimit (default
  1000) applied before get() — symmetry with UserSearchProvider's
  cap. In-memory rank then trim preserved.
- SearchResult.php: toArray() + jsonSerialize() include category +
  score (were missing); phpDoc return shapes updated.

Tests:
- SearchSystemTest.php: workspace candidate-cap 'Ugly' coverage;
  round-trip serialisation coverage for category + score.

Verification: php -l clean, pint --dirty + pint --test --dirty pass,
pest 22 tests / 79 assertions pass.

Closes residual r3 findings on #10

Co-authored-by: Codex <noreply@openai.com>
@Snider Snider merged commit f94519d into main Apr 27, 2026
2 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant