mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-11 02:50:29 +00:00
3a507cced2
- 🔍 Added `resolveInScope` method to `ResolvesEntities` for scoped entity resolution with stricter control. - 👥 Introduced `AddMeetupToMineTool` MCP tool for adding external meetups to "My Meetups." - 🛠️ Updated `ListMyMeetupsTool` and `ShowMyMeetupTool` to include both created and joined meetups. - 📚 Updated `Meetup` model with `associatedWith` scope for querying user-related meetups. - ✅ Expanded feature tests for meetup membership, creator permissions, and scoped tool usage. - 🛡️ Unified access checks across Livewire and APIs to restrict editing meetup details to creators or super-admins. - 🔗 Registered `AddMeetupToMineTool` in `EinundzwanzigServer`.
181 lines
6.3 KiB
PHP
181 lines
6.3 KiB
PHP
<?php
|
|
|
|
namespace App\Mcp\Tools\Concerns;
|
|
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Laravel\Mcp\Request;
|
|
use Laravel\Mcp\Response;
|
|
|
|
/**
|
|
* Löst Entitäten über ihren Namen (oder optional ihre ID) auf, damit Nutzer keine
|
|
* internen IDs kennen müssen. Bei Mehrdeutigkeit oder fehlendem Treffer wird eine
|
|
* Auswahlliste der passenden Einträge als Fehlertext zurückgegeben.
|
|
*/
|
|
trait ResolvesEntities
|
|
{
|
|
/**
|
|
* Löst einen vom authentifizierten Nutzer erstellten Datensatz auf.
|
|
*
|
|
* Die ID-Variante sucht über alle Datensätze (die Ownership-Prüfung übernimmt die
|
|
* aufrufende Gate-Policy); die Namens-Variante ist auf die eigenen Datensätze
|
|
* beschränkt und dient damit zugleich als Auswahlliste.
|
|
*
|
|
* @param class-string<Model> $modelClass
|
|
*/
|
|
protected function resolveOwnedByName(Request $request, string $modelClass, string $label, string $nameParam, string $column = 'name'): Model|Response
|
|
{
|
|
$id = $request->get('id');
|
|
|
|
if ($this->present($id)) {
|
|
$found = $modelClass::query()->whereKey($id)->first();
|
|
|
|
if ($found !== null) {
|
|
return $found;
|
|
}
|
|
}
|
|
|
|
$owned = $modelClass::query()->where('created_by', $request->user()?->getAuthIdentifier());
|
|
$name = $request->get($nameParam);
|
|
|
|
if ($this->present($name)) {
|
|
$matches = $this->matchByName($owned, (string) $name, $column);
|
|
|
|
if ($matches->count() === 1) {
|
|
return $matches->first();
|
|
}
|
|
|
|
if ($matches->count() > 1) {
|
|
return Response::error("Mehrere {$label} passen zu \"{$name}\": ".$matches->pluck($column)->join('; ').'. Bitte den genauen Namen angeben.');
|
|
}
|
|
}
|
|
|
|
return $this->optionsError($owned, $label, $column);
|
|
}
|
|
|
|
/**
|
|
* Löst einen Datensatz per ID oder Name STRIKT innerhalb des übergebenen Scopes auf
|
|
* (der Scope ist zugleich die Autorisierung). Bei Mehrdeutigkeit oder fehlendem Treffer
|
|
* wird eine Auswahlliste der Einträge des Scopes zurückgegeben.
|
|
*/
|
|
protected function resolveInScope(Builder $scope, Request $request, string $label, string $nameParam, string $column = 'name'): Model|Response
|
|
{
|
|
$id = $request->get('id');
|
|
|
|
if ($this->present($id)) {
|
|
$byId = (clone $scope)->whereKey($id)->first();
|
|
|
|
if ($byId !== null) {
|
|
return $byId;
|
|
}
|
|
}
|
|
|
|
$name = $request->get($nameParam);
|
|
|
|
if ($this->present($name)) {
|
|
$matches = $this->matchByName(clone $scope, (string) $name, $column);
|
|
|
|
if ($matches->count() === 1) {
|
|
return $matches->first();
|
|
}
|
|
|
|
if ($matches->count() > 1) {
|
|
return Response::error("Mehrere {$label} passen zu \"{$name}\": ".$matches->pluck($column)->join('; ').'. Bitte den genauen Namen angeben.');
|
|
}
|
|
}
|
|
|
|
return $this->optionsError(clone $scope, $label, $column);
|
|
}
|
|
|
|
/**
|
|
* Löst einen Fremdschlüssel über den Namen auf und schreibt die ID in den Request,
|
|
* damit die nachgelagerte Validierung sie sieht. Gibt null zurück, wenn nichts zu tun
|
|
* ist (ID bereits gesetzt, oder optionaler FK ohne Namen), sonst eine Fehler-Response.
|
|
*/
|
|
protected function mergeForeignKey(Request $request, string $nameParam, string $idKey, Builder $query, string $label, bool $required = true): ?Response
|
|
{
|
|
if ($this->present($request->get($idKey))) {
|
|
return null;
|
|
}
|
|
|
|
if (! $this->present($request->get($nameParam))) {
|
|
return $required ? Response::error("Bitte einen Namen für {$label} angeben.") : null;
|
|
}
|
|
|
|
$model = $this->resolveGlobalByName($query, $request->get($nameParam), $label);
|
|
|
|
if ($model instanceof Response) {
|
|
return $model;
|
|
}
|
|
|
|
$request->merge([$idKey => $model->id]);
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Löst einen global sichtbaren Datensatz (z. B. Stadt, Land, Referent, Kurs, Ort)
|
|
* über seinen Namen auf.
|
|
*/
|
|
protected function resolveGlobalByName(Builder $query, ?string $name, string $label, string $column = 'name'): Model|Response
|
|
{
|
|
if (! $this->present($name)) {
|
|
return Response::error("Bitte einen Namen für {$label} angeben.");
|
|
}
|
|
|
|
$matches = $this->matchByName($query, (string) $name, $column);
|
|
|
|
if ($matches->count() === 1) {
|
|
return $matches->first();
|
|
}
|
|
|
|
if ($matches->isEmpty()) {
|
|
return Response::error("{$label} \"{$name}\" wurde nicht gefunden. Nutze das passende search-Tool, um den genauen Namen zu ermitteln.");
|
|
}
|
|
|
|
return Response::error("Mehrere {$label} passen zu \"{$name}\": ".$matches->pluck($column)->take(15)->join('; ').'. Bitte präziser angeben.');
|
|
}
|
|
|
|
/**
|
|
* Case-insensitive Treffer in einer einzigen Abfrage: Teilstring-Suche, exakte
|
|
* Treffer nach vorne sortiert (und dadurch nie vom Limit abgeschnitten). Existiert
|
|
* mindestens ein exakter Treffer, gewinnt dieser; sonst zählen die Teiltreffer.
|
|
* DB-portabel über LOWER().
|
|
*
|
|
* @return Collection<int, Model>
|
|
*/
|
|
private function matchByName(Builder $query, string $name, string $column): Collection
|
|
{
|
|
$needle = mb_strtolower(trim($name));
|
|
|
|
$matches = (clone $query)
|
|
->whereRaw('LOWER('.$column.') LIKE ?', ['%'.$needle.'%'])
|
|
->orderByRaw('CASE WHEN LOWER('.$column.') = ? THEN 0 ELSE 1 END', [$needle])
|
|
->limit(25)
|
|
->get();
|
|
|
|
$exact = $matches->filter(
|
|
fn (Model $model): bool => mb_strtolower((string) $model->getAttribute($column)) === $needle
|
|
)->values();
|
|
|
|
return $exact->isNotEmpty() ? $exact : $matches;
|
|
}
|
|
|
|
private function optionsError(Builder $owned, string $label, string $column): Response
|
|
{
|
|
$names = (clone $owned)->orderBy($column)->limit(50)->pluck($column);
|
|
|
|
if ($names->isEmpty()) {
|
|
return Response::error("Du hast noch keine {$label} angelegt.");
|
|
}
|
|
|
|
return Response::error("{$label} nicht gefunden. Deine {$label}: ".$names->join('; ').'.');
|
|
}
|
|
|
|
private function present(mixed $value): bool
|
|
{
|
|
return $value !== null && $value !== '';
|
|
}
|
|
}
|