From d0f2ee7d7e12f688b7707c978827ee8d070eef2d Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Thu, 25 Jun 2026 22:14:43 +0200 Subject: [PATCH] Use an enum for tag match type --- app/Enums/TagMatchType.php | 16 ++++++++++ .../Controllers/Api/V1/ReportController.php | 2 +- .../Api/V1/TimeEntryController.php | 4 +-- .../Requests/V1/Report/ReportStoreRequest.php | 12 +++++++- .../TimeEntryAggregateExportRequest.php | 12 +++++++- .../TimeEntry/TimeEntryAggregateRequest.php | 12 +++++++- .../TimeEntry/TimeEntryIndexExportRequest.php | 12 +++++++- .../V1/TimeEntry/TimeEntryIndexRequest.php | 12 +++++++- .../V1/Report/DetailedReportResource.php | 2 +- app/Service/Dto/ReportPropertiesDto.php | 29 ++++--------------- app/Service/TimeEntryFilter.php | 17 +++-------- 11 files changed, 85 insertions(+), 45 deletions(-) create mode 100644 app/Enums/TagMatchType.php diff --git a/app/Enums/TagMatchType.php b/app/Enums/TagMatchType.php new file mode 100644 index 00000000..6bb92e0a --- /dev/null +++ b/app/Enums/TagMatchType.php @@ -0,0 +1,16 @@ +setClientIds($request->input('properties.client_ids', null)); $properties->setProjectIds($request->input('properties.project_ids', null)); $properties->setTagIds($request->input('properties.tag_ids', null)); - $properties->setTagMatchType($request->input('properties.tag_match_type', null)); + $properties->setTagMatchType($request->getPropertyTagMatchType()); $properties->setTaskIds($request->input('properties.task_ids', null)); $properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start; $timezone = $user->timezone; diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index e93c2122..1778b41b 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -203,7 +203,7 @@ class TimeEntryController extends Controller $filter->addMemberIdFilter($member); $filter->addMemberIdsFilter($request->input('member_ids')); $filter->addProjectIdsFilter($request->input('project_ids')); - $filter->addTagIdsFilter($request->input('tag_ids'), $request->input('tag_match_type')); + $filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType()); $filter->addTaskIdsFilter($request->input('task_ids')); $filter->addClientIdsFilter($request->input('client_ids')); $filter->addBillableFilter($request->input('billable')); @@ -559,7 +559,7 @@ class TimeEntryController extends Controller $filter->addMemberIdFilter($member); $filter->addMemberIdsFilter($request->input('member_ids')); $filter->addProjectIdsFilter($request->input('project_ids')); - $filter->addTagIdsFilter($request->input('tag_ids'), $request->input('tag_match_type')); + $filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType()); $filter->addTaskIdsFilter($request->input('task_ids')); $filter->addClientIdsFilter($request->input('client_ids')); $filter->addBillableFilter($request->input('billable')); diff --git a/app/Http/Requests/V1/Report/ReportStoreRequest.php b/app/Http/Requests/V1/Report/ReportStoreRequest.php index c959ae93..68747d5f 100644 --- a/app/Http/Requests/V1/Report/ReportStoreRequest.php +++ b/app/Http/Requests/V1/Report/ReportStoreRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\V1\Report; +use App\Enums\TagMatchType; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryRoundingType; @@ -127,7 +128,7 @@ class ReportStoreRequest extends BaseFormRequest 'properties.tag_match_type' => [ 'nullable', 'string', - 'in:'.TimeEntryFilter::TAG_MATCH_TYPE_CONTAINS.','.TimeEntryFilter::TAG_MATCH_TYPE_NOT_CONTAINS, + Rule::enum(TagMatchType::class), ], 'properties.task_ids' => [ 'nullable', @@ -254,6 +255,15 @@ class ReportStoreRequest extends BaseFormRequest return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group')); } + public function getPropertyTagMatchType(): ?TagMatchType + { + if (! $this->has('properties.tag_match_type') || $this->input('properties.tag_match_type') === null) { + return null; + } + + return TagMatchType::from($this->input('properties.tag_match_type')); + } + public function getPropertyRoundingType(): ?TimeEntryRoundingType { if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) { diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php index 97339377..519a2a10 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; use App\Enums\ExportFormat; +use App\Enums\TagMatchType; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryRoundingType; @@ -141,7 +142,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest ], 'tag_match_type' => [ 'string', - 'in:'.TimeEntryFilter::TAG_MATCH_TYPE_CONTAINS.','.TimeEntryFilter::TAG_MATCH_TYPE_NOT_CONTAINS, + Rule::enum(TagMatchType::class), ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ @@ -250,6 +251,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest return ExportFormat::from($this->validated('format')); } + public function getTagMatchType(): ?TagMatchType + { + if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) { + return null; + } + + return TagMatchType::from($this->validated('tag_match_type')); + } + public function getRoundingType(): ?TimeEntryRoundingType { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php index 5ca9bb18..4a223d90 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; +use App\Enums\TagMatchType; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryRoundingType; use App\Http\Requests\V1\BaseFormRequest; @@ -127,7 +128,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest ], 'tag_match_type' => [ 'string', - 'in:'.TimeEntryFilter::TAG_MATCH_TYPE_CONTAINS.','.TimeEntryFilter::TAG_MATCH_TYPE_NOT_CONTAINS, + Rule::enum(TagMatchType::class), ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ @@ -212,6 +213,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null; } + public function getTagMatchType(): ?TagMatchType + { + if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) { + return null; + } + + return TagMatchType::from($this->validated('tag_match_type')); + } + public function getRoundingType(): ?TimeEntryRoundingType { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php index 0b95c966..30246f3c 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; use App\Enums\ExportFormat; +use App\Enums\TagMatchType; use App\Enums\TimeEntryRoundingType; use App\Models\Client; use App\Models\Member; @@ -112,7 +113,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest ], 'tag_match_type' => [ 'string', - 'in:'.TimeEntryFilter::TAG_MATCH_TYPE_CONTAINS.','.TimeEntryFilter::TAG_MATCH_TYPE_NOT_CONTAINS, + Rule::enum(TagMatchType::class), ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ @@ -219,6 +220,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest return ExportFormat::from($this->validated('format')); } + public function getTagMatchType(): ?TagMatchType + { + if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) { + return null; + } + + return TagMatchType::from($this->validated('tag_match_type')); + } + public function getRoundingType(): ?TimeEntryRoundingType { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index 55f9ff7b..fd906a4b 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; +use App\Enums\TagMatchType; use App\Enums\TimeEntryRoundingType; use App\Http\Requests\V1\BaseFormRequest; use App\Models\Client; @@ -105,7 +106,7 @@ class TimeEntryIndexRequest extends BaseFormRequest ], 'tag_match_type' => [ 'string', - 'in:'.TimeEntryFilter::TAG_MATCH_TYPE_CONTAINS.','.TimeEntryFilter::TAG_MATCH_TYPE_NOT_CONTAINS, + Rule::enum(TagMatchType::class), ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ @@ -194,6 +195,15 @@ class TimeEntryIndexRequest extends BaseFormRequest return $this->has('offset') ? (int) $this->validated('offset', 0) : 0; } + public function getTagMatchType(): ?TagMatchType + { + if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) { + return null; + } + + return TagMatchType::from($this->validated('tag_match_type')); + } + public function getRoundingType(): ?TimeEntryRoundingType { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { diff --git a/app/Http/Resources/V1/Report/DetailedReportResource.php b/app/Http/Resources/V1/Report/DetailedReportResource.php index 59b8db28..b8e6e9d4 100644 --- a/app/Http/Resources/V1/Report/DetailedReportResource.php +++ b/app/Http/Resources/V1/Report/DetailedReportResource.php @@ -57,7 +57,7 @@ class DetailedReportResource extends BaseResource /** @var array|null $tags_ids Filter by tag IDs, tag IDs are OR combined */ 'tag_ids' => $this->resource->properties->tagIds?->toArray(), /** @var string|null $tag_match_type Tag match type */ - 'tag_match_type' => $this->resource->properties->tagMatchType, + 'tag_match_type' => $this->resource->properties->tagMatchType?->value, /** @var array|null $task_ids Filter by task IDs, task IDs are OR combined */ 'task_ids' => $this->resource->properties->taskIds?->toArray(), /** @var string|null $rounding_type Rounding type for time entries */ diff --git a/app/Service/Dto/ReportPropertiesDto.php b/app/Service/Dto/ReportPropertiesDto.php index 1be563d1..6a476342 100644 --- a/app/Service/Dto/ReportPropertiesDto.php +++ b/app/Service/Dto/ReportPropertiesDto.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Service\Dto; +use App\Enums\TagMatchType; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryRoundingType; @@ -56,7 +57,7 @@ class ReportPropertiesDto implements Castable */ public ?Collection $tagIds = null; - public ?string $tagMatchType = null; + public ?TagMatchType $tagMatchType = null; /** * @var Collection|null @@ -117,7 +118,7 @@ class ReportPropertiesDto implements Castable $dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null; $dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null; $dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null; - $dto->tagMatchType = isset($data->tagMatchType) ? ReportPropertiesDto::tagMatchTypeValue($data->tagMatchType) : null; + $dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null; $dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null; $dto->group = TimeEntryAggregationType::from($data->group); $dto->subGroup = TimeEntryAggregationType::from($data->subGroup); @@ -147,7 +148,7 @@ class ReportPropertiesDto implements Castable 'clientIds' => $value->clientIds?->toArray(), 'projectIds' => $value->projectIds?->toArray(), 'tagIds' => $value->tagIds?->toArray(), - 'tagMatchType' => $value->tagMatchType, + 'tagMatchType' => $value->tagMatchType?->value, 'taskIds' => $value->taskIds?->toArray(), 'group' => $value->group->value, 'subGroup' => $value->subGroup->value, @@ -188,24 +189,6 @@ class ReportPropertiesDto implements Castable return $collection; } - /** - * @return 'contains'|'not_contains'|null - */ - public static function tagMatchTypeValue(mixed $tagMatchType): ?string - { - if ($tagMatchType === null) { - return null; - } - if (! is_string($tagMatchType)) { - throw new \InvalidArgumentException('The given tag match type is not a string'); - } - if (! in_array($tagMatchType, [TimeEntryFilter::TAG_MATCH_TYPE_CONTAINS, TimeEntryFilter::TAG_MATCH_TYPE_NOT_CONTAINS], true)) { - throw new \InvalidArgumentException('The given tag match type is not valid'); - } - - return $tagMatchType; - } - /** * @param array|null $memberIds */ @@ -238,9 +221,9 @@ class ReportPropertiesDto implements Castable $this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null; } - public function setTagMatchType(mixed $tagMatchType): void + public function setTagMatchType(?TagMatchType $tagMatchType): void { - $this->tagMatchType = ReportPropertiesDto::tagMatchTypeValue($tagMatchType); + $this->tagMatchType = $tagMatchType; } /** diff --git a/app/Service/TimeEntryFilter.php b/app/Service/TimeEntryFilter.php index 8e35d42b..150dbb05 100644 --- a/app/Service/TimeEntryFilter.php +++ b/app/Service/TimeEntryFilter.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Service; +use App\Enums\TagMatchType; use App\Models\Member; use App\Models\TimeEntry; use Illuminate\Database\Eloquent\Builder; @@ -14,10 +15,6 @@ class TimeEntryFilter { public const string NONE_VALUE = 'none'; - public const string TAG_MATCH_TYPE_CONTAINS = 'contains'; - - public const string TAG_MATCH_TYPE_NOT_CONTAINS = 'not_contains'; - /** * @var Builder */ @@ -196,18 +193,12 @@ class TimeEntryFilter /** * @param array|null $tagIds */ - public function addTagIdsFilter(?array $tagIds, ?string $tagMatchType = self::TAG_MATCH_TYPE_CONTAINS): self + public function addTagIdsFilter(?array $tagIds, ?TagMatchType $tagMatchType = TagMatchType::Contains): self { if ($tagIds === null) { return $this; } - if ($tagMatchType === null) { - $tagMatchType = self::TAG_MATCH_TYPE_CONTAINS; - } - if (! in_array($tagMatchType, [self::TAG_MATCH_TYPE_CONTAINS, self::TAG_MATCH_TYPE_NOT_CONTAINS], true)) { - Log::warning('Invalid tag match type value', ['value' => $tagMatchType]); - $tagMatchType = self::TAG_MATCH_TYPE_CONTAINS; - } + $tagMatchType ??= TagMatchType::Contains; $includeNone = in_array(self::NONE_VALUE, $tagIds, true); $tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE)); // An empty selection (no tag IDs and not filtering for "none") is no constraint, so apply nothing. @@ -227,7 +218,7 @@ class TimeEntryFilter } }; - if ($tagMatchType === self::TAG_MATCH_TYPE_NOT_CONTAINS) { + if ($tagMatchType === TagMatchType::NotContains) { $this->builder->where(function (Builder $builder) use ($tagCondition, $includeNone): void { $builder->whereNot($tagCondition); if (! $includeNone) {