first commit

This commit is contained in:
2026-02-18 19:54:52 +07:00
commit 8e070562cb
101 changed files with 13462 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
interface FilterFactoryInterface
{
public function make(string $name, array $values): FilterInterface;
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
use Closure;
use Illuminate\Contracts\Database\Eloquent\Builder;
interface FilterInterface
{
public function __invoke(Builder $builder, Closure $next): Builder;
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Throwable;
interface QueryFilterPipelineInterface
{
/**
* @throws Throwable
*/
public function applyFilters(Builder $builder, RequestFilterInterface $filterRequest): LengthAwarePaginator;
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
interface RequestFilterInterface
{
/**
* Возвращает список фильтруемых полей
* @return array<string>
*/
public function filters(): array;
/**
* Возвращает значения фильтров
* @return array<string, mixed>
*/
public function values(): array;
/**
* Пагинация
* @return int|null если null, без пагинации
*/
public function perPage(): ?int;
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Contracts;
use App\Models\Task;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Throwable;
interface TaskServiceInterface
{
/**
* @throws Throwable
*/
public function list(RequestFilterInterface $filters): LengthAwarePaginator;
/**
* @throws Throwable
*/
public function create(array $data): Task;
public function show(Task $task): Task;
/**
* @throws Throwable
*/
public function update(Task $task, array $data): Task;
public function delete(Task $task): void;
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Filters\Factory;
use App\Contracts\FilterFactoryInterface;
use App\Contracts\FilterInterface;
use Filter\FilterException;
use Filter\FilterFailedException;
use Throwable;
final readonly class FilterFactory implements FilterFactoryInterface
{
/**
* @param array<string, class-string<FilterInterface>> $services
*/
public function __construct(private array $services)
{
}
/**
* @throws FilterException
*/
public function make(string $name, array $values): FilterInterface
{
if (!isset($this->services[$name])) {
throw new FilterException(
sprintf('Filter "%s" not found', $name)
);
}
$filterClass = $this->services[$name];
$value = $values[$name] ?? null;
try {
return new $filterClass($value);
} catch (Throwable $e) {
throw new FilterFailedException(
sprintf('Failed to create filter "%s": %s', $name, $e->getMessage()),
0,
$e
);
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use App\Contracts\FilterInterface;
use Closure;
use Illuminate\Contracts\Database\Eloquent\Builder;
final readonly class IsDoneFilter implements FilterInterface
{
public function __construct(private ?bool $value)
{
}
public function __invoke(Builder $builder, Closure $next): Builder
{
if (!is_null($this->value)) {
$builder->where('is_done', $this->value);
}
return $next($builder);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use App\Contracts\FilterInterface;
use Closure;
use Illuminate\Contracts\Database\Eloquent\Builder;
final readonly class SearchTitleFilter implements FilterInterface
{
public function __construct(private ?string $value)
{
}
public function __invoke(Builder $builder, Closure $next): Builder
{
if (!empty($this->value)) {
$builder->where('title', 'like', "%$this->value%");
}
return $next($builder);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use App\Contracts\FilterInterface;
use Closure;
use Illuminate\Contracts\Database\Eloquent\Builder;
final readonly class SortingFilter implements FilterInterface
{
public function __construct(private array $params)
{
}
public function __invoke(Builder $builder, Closure $next): Builder
{
if (!$this->params['column'] && !$this->params['direction']) {
return $next($builder);
}
$column = $this->params['column'] ?? config('filters.default.sort');
$direction = $this->params['direction'] ?? config('filters.default.order');
return $next($builder->orderBy($column, $direction));
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use App\Contracts\FilterInterface;
use Closure;
use Illuminate\Contracts\Database\Eloquent\Builder;
final readonly class TagFilter implements FilterInterface
{
public function __construct(private ?string $value)
{
}
public function __invoke(Builder $builder, Closure $next): Builder
{
if (!empty($this->value)) {
$builder->whereHas('tags', fn($q) => $q->where('name', $this->value)
);
}
return $next($builder);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Routing\Controller as BaseController;
abstract class Controller extends BaseController
{
use AuthorizesRequests;
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\StoreTagRequest;
use App\Http\Requests\UpdateTagRequest;
use App\Models\Tag;
class TagController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
//
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreTagRequest $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(Tag $tag)
{
//
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Tag $tag)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateTagRequest $request, Tag $tag)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Tag $tag)
{
//
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Contracts\TaskServiceInterface;
use App\Http\Requests\IndexTaskRequest;
use App\Http\Requests\StoreTaskRequest;
use App\Http\Requests\UpdateTaskRequest;
use App\Http\Resources\TaskResource;
use App\Models\Task;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Throwable;
class TaskController extends Controller
{
public function __construct(private readonly TaskServiceInterface $service)
{
}
/**
* Display a listing of the resource.
*/
public function index(IndexTaskRequest $request): JsonResponse
{
try {
$tasks = $this->service->list($request);
return TaskResource::collection($tasks)->response();
} catch (Throwable $e) {
return response()->json([
'error' => 'Service unavailable',
'message' => $e->getMessage(),
], 503);
}
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreTaskRequest $request): JsonResponse
{
try {
$task = $this->service->create($request->validated());
return new TaskResource($task->load('tags'))
->response()
->setStatusCode(201);
} catch (Throwable $e) {
return response()->json([
'error' => 'Service unavailable',
'message' => $e->getMessage(),
], 503);
}
}
/**
* Display the specified resource.
*/
public function show(Task $task): JsonResponse
{
$task = $this->service->show($task);
return new TaskResource($task->load('tags'))->response();
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Task $task)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateTaskRequest $request, Task $task): JsonResponse
{
try {
$task = $this->service->update($task, $request->validated());
return new TaskResource($task->load('tags'))->response();
} catch (Throwable $e) {
return response()->json([
'error' => 'Service unavailable',
'message' => $e->getMessage(),
], 503);
}
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Task $task): Response
{
$this->service->delete($task);
return response()->noContent();
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use App\Contracts\RequestFilterInterface;
use Illuminate\Foundation\Http\FormRequest;
class IndexTaskRequest extends FormRequest implements RequestFilterInterface
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'is_done' => ['nullable', 'boolean'],
'tag' => ['nullable', 'string', 'max:50'],
'q' => ['nullable', 'string', 'max:200'],
'sort' => ['nullable', 'in:created_at,due_at'],
'order' => ['nullable', 'in:asc,desc'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:' . config('filters.pagination.max_per_page')],
];
}
public function filters(): array
{
return [
'is_done',
'tag',
'search',
'sort',
];
}
public function values(): array
{
return [
'is_done' => $this->filled('is_done') ? (bool)$this->input('is_done') : null,
'tag' => $this->input('tag'),
'search' => $this->input('q'),
'sort' => [
'column' => $this->input('sort'),
'direction' => $this->input('order'),
],
];
}
public function perPage(): ?int
{
return (int)$this->input('per_page');
//?? config('filters.pagination.per_page')
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreTagRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
//
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreTaskRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:200'],
'is_done' => ['nullable', 'boolean'],
'due_at' => ['nullable', 'date', 'date_format:Y-m-d\TH:i:s\Z'],
'tags' => ['nullable', 'array'],
'tags.*' => ['string', 'max:50'],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateTagRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
//
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateTaskRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'title' => ['sometimes', 'required', 'string', 'max:200'],
'is_done' => ['sometimes', 'boolean'],
'due_at' => ['nullable', 'date', 'date_format:Y-m-d\TH:i:s\Z'],
'tags' => ['nullable', 'array'],
'tags.*' => ['string', 'max:50'],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Tag;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Tag
*/
class TagResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Task
*/
class TaskResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'is_done' => $this->is_done,
'due_at' => optional($this->due_at)?->toISOString(),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
'tags' => TagResource::collection(
$this->whenLoaded('tags')
),
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $name
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Task> $tasks
* @property-read int|null $tasks_count
* @method static \Database\Factories\TagFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|Tag newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Tag newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Tag query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Tag whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Tag whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Tag whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Tag whereUpdatedAt($value)
* @mixin \Eloquent
*/
class Tag extends Model
{
/** @use HasFactory<\Database\Factories\TagFactory> */
use HasFactory;
protected $fillable = ['name'];
public function setNameAttribute(string $value): void
{
$this->attributes['name'] = mb_strtolower(trim($value));
}
public function tasks(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Task::class);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $title
* @property bool $is_done
* @property \Illuminate\Support\Carbon|null $due_at
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Tag> $tags
* @property-read int|null $tags_count
* @method static \Database\Factories\TaskFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereDueAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereIsDone($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereTitle($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereUpdatedAt($value)
* @mixin \Eloquent
*/
class Task extends Model
{
/** @use HasFactory<\Database\Factories\TaskFactory> */
use HasFactory;
protected $fillable = ['title', 'is_done', 'due_at'];
protected function casts(): array
{
return [
'is_done' => 'boolean',
'due_at' => 'datetime',
];
}
public function tags(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
/**
* @property int $id
* @property string $name
* @property string $email
* @property \Illuminate\Support\Carbon|null $email_verified_at
* @property string $password
* @property string|null $remember_token
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
* @property-read int|null $notifications_count
* @method static \Database\Factories\UserFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|User query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereEmail($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereEmailVerifiedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User wherePassword($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereRememberToken($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereUpdatedAt($value)
* @mixin \Eloquent
*/
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Pipelines;
use App\Contracts\FilterFactoryInterface;
use App\Contracts\QueryFilterPipelineInterface;
use App\Contracts\RequestFilterInterface;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Pipeline\Pipeline;
use InvalidArgumentException;
use Log;
use Throwable;
final readonly class QueryFilterPipeline implements QueryFilterPipelineInterface
{
public function __construct(private Pipeline $pipeline, private FilterFactoryInterface $factory)
{
}
/**
* @inheritDoc
*/
public function applyFilters(Builder $builder, RequestFilterInterface $filterRequest): LengthAwarePaginator
{
$perPage = $filterRequest->perPage();
$perPage ??= config('filters.pagination.per_page', 15);
$maxPerPage = config('filters.pagination.max_per_page', 50);
if ($perPage > $maxPerPage) {
throw new InvalidArgumentException(
sprintf('per_page cannot exceed %d', $maxPerPage)
);
}
try {
$values = $filterRequest->values();
$filters = collect($filterRequest->filters())
->map(fn($name) => $this->factory->make($name, $values))
->filter()
->values()
->all();
$builder = $this->pipeline
->send($builder)
->through($filters)
->thenReturn();
} catch (Throwable $e) {
Log::error('Pipeline execution failed', [
'error' => $e->getMessage(),
]);
throw $e;
}
/**
* @var Builder $builder
*/
return $builder->paginate($perPage);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Policies;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class TagPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return false;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Tag $tag): bool
{
return false;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Tag $tag): bool
{
return false;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Tag $tag): bool
{
return false;
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Tag $tag): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Tag $tag): bool
{
return false;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Policies;
use App\Models\Task;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class TaskPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return false;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Task $task): bool
{
return false;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Task $task): bool
{
return false;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Task $task): bool
{
return false;
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Task $task): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Task $task): bool
{
return false;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Providers;
use App\Contracts\FilterFactoryInterface;
use App\Contracts\QueryFilterPipelineInterface;
use App\Contracts\TaskServiceInterface;
use App\Filters\Factory\FilterFactory;
use App\Pipelines\QueryFilterPipeline;
use App\Services\TaskService;
use Illuminate\Pipeline\Pipeline;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->registerFilters();
$this->registerPipeline();
$this->app->bind(TaskServiceInterface::class, TaskService::class);
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
private function registerFilters(): void
{
$this->app->bind(FilterFactoryInterface::class, function () {
return new FilterFactory(
config('filters.available', [])
);
});
}
private function registerPipeline(): void
{
$this->app->bind(QueryFilterPipelineInterface::class, function ($app) {
return new QueryFilterPipeline(
$app->make(Pipeline::class),
$app->make(FilterFactoryInterface::class)
);
});
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Contracts\QueryFilterPipelineInterface;
use App\Contracts\RequestFilterInterface;
use App\Contracts\TaskServiceInterface;
use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
final readonly class TaskService implements TaskServiceInterface
{
public function __construct(
private QueryFilterPipelineInterface $pipeline
) {
}
/**
* @inheritDoc
*/
public function list(RequestFilterInterface $filters): LengthAwarePaginator
{
$builder = Task::query()
->with('tags');
return $this->pipeline->applyFilters($builder, $filters);
}
/**
* @inheritDoc
*/
public function create(array $data): Task
{
return DB::transaction(function () use ($data) {
$tags = $data['tags'] ?? [];
unset($data['tags']);
$task = Task::create($data);
if (!empty($tags)) {
$this->syncTags($task, $tags);
}
return $task->load('tags');
});
}
public function show(Task $task): Task
{
return $task->load('tags');
}
/**
* @inheritDoc
*/
public function update(Task $task, array $data): Task
{
return DB::transaction(function () use ($task, $data) {
$tags = $data['tags'] ?? null;
unset($data['tags']);
$task->update($data);
if (is_array($tags)) {
$this->syncTags($task, $tags);
}
return $task->load('tags');
});
}
public function delete(Task $task): void
{
$task->delete();
}
private function syncTags(Task $task, array $tagNames): void
{
$tagIds = collect($tagNames)
->filter()
->unique()
->map(function (string $name) {
try {
return Tag::firstOrCreate([
'name' => $name,
])->id;
} catch (QueryException) {
return Tag::where('name', $name)->value('id');
}
})
->all();
$task->tags()->sync($tagIds);
}
}