feat(api): add tags to resource creation

Normalize tag names before attaching them, reject names that are too short
after sanitization, and return 404 when removing tags not attached to the
resource.

Adds a per-team unique tag-name index and migrates duplicate tags onto the
kept record before creating the constraint.
This commit is contained in:
Andras Bacsai
2026-07-07 13:56:33 +02:00
parent f617e58401
commit 11b35ba3c1
12 changed files with 1466 additions and 80 deletions
@@ -6,7 +6,6 @@ use App\Http\Controllers\Api\TagsController;
use App\Models\Tag;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
trait HandlesTagsApi
@@ -46,7 +45,7 @@ trait HandlesTagsApi
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@@ -65,9 +64,9 @@ trait HandlesTagsApi
}
$validator = Validator::make($request->all(), [
'tag_name' => 'required_without:tag_names|string|min:2',
'tag_name' => 'required_without:tag_names|string',
'tag_names' => 'required_without:tag_name|array|min:1',
'tag_names.*' => 'string|min:2',
'tag_names.*' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']);
@@ -85,7 +84,14 @@ trait HandlesTagsApi
], 422);
}
$tagNames = $request->has('tag_names') ? $request->tag_names : [$request->tag_name];
$tagNames = $this->normalizeTagNames($request->has('tag_names') ? $request->tag_names : [$request->tag_name]);
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
if (! empty($invalidTags)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['tag_name' => ['Each tag name must be at least 2 characters after sanitization.']],
], 422);
}
$this->attachTagsToResource($resource, $tagNames, $teamId);
@@ -111,32 +117,58 @@ trait HandlesTagsApi
return response()->json(['message' => 'Tag not found.'], 404);
}
$resource->tags()->detach($tag->id);
if (DB::table('taggables')->where('tag_id', $tag->id)->count() === 0) {
$tag->delete();
if (! $resource->tags()->whereKey($tag->id)->exists()) {
return response()->json(['message' => 'Tag not found on resource.'], 404);
}
$resource->tags()->detach($tag->id);
$tag->deleteIfOrphaned();
return response()->json(['message' => 'Tag removed.']);
}
protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void
{
foreach ($tagNames as $tagName) {
$tagName = strtolower(strip_tags($tagName));
if (strlen($tagName) < 2) {
foreach ($this->normalizeTagNames($tagNames) as $tagName) {
if (mb_strlen($tagName) < 2) {
continue;
}
$tag = Tag::where('team_id', $teamId)->where('name', $tagName)->first();
if (! $tag) {
$tag = Tag::create([
'name' => $tagName,
'team_id' => $teamId,
]);
}
$tag = Tag::query()->createOrFirst([
'team_id' => $teamId,
'name' => $tagName,
]);
$resource->tags()->syncWithoutDetaching([$tag->id]);
}
}
protected function validateTagsParameter(Request $request): ?JsonResponse
{
if (! $request->has('tags')) {
return null;
}
$tagNames = $this->normalizeTagNames($request->input('tags', []));
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
if (! empty($invalidTags)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['tags' => ['Each tag name must be at least 2 characters after sanitization.']],
], 422);
}
$request->merge(['tags' => $tagNames]);
return null;
}
protected function normalizeTagNames(array $tagNames): array
{
return collect($tagNames)
->map(fn ($tagName): string => strtolower(trim(strip_tags((string) $tagName))))
->unique()
->values()
->all();
}
}