mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-13 16:23:18 +01:00
Add goals feature for personal goals with filters
This commit is contained in:
143
app/Http/Requests/V1/Goal/GoalFilterRules.php
Normal file
143
app/Http/Requests/V1/Goal/GoalFilterRules.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Goal;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Service\Dto\GoalFiltersDto;
|
||||
use App\Service\TimeEntryFilter;
|
||||
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
* Shared validation rules for the filters of a goal.
|
||||
*
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
trait GoalFilterRules
|
||||
{
|
||||
/**
|
||||
* @param bool $forEveryMember Whether the goal counts every member instead of one member
|
||||
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
|
||||
*/
|
||||
protected function filterRules(bool $forEveryMember): array
|
||||
{
|
||||
return [
|
||||
'filters' => [
|
||||
'sometimes',
|
||||
'array',
|
||||
],
|
||||
// Filter by member IDs, member IDs are OR combined. Only for goals that count every member, null means every member.
|
||||
'filters.member_ids' => [
|
||||
'nullable',
|
||||
'array',
|
||||
// "prohibited" still lets null and an empty array through, both mean "no restriction"
|
||||
...($forEveryMember ? [] : ['prohibited']),
|
||||
],
|
||||
'filters.member_ids.*' => [
|
||||
'string',
|
||||
'distinct',
|
||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization')
|
||||
->where('role', '!=', Role::Placeholder->value);
|
||||
})->uuid(),
|
||||
],
|
||||
// Filter by project IDs, project IDs are OR combined, "none" matches entries without a project
|
||||
'filters.project_ids' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'filters.project_ids.*' => [
|
||||
'string',
|
||||
$this->idOrNoneExistsInOrganization(Project::class),
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined, "none" matches entries without a task
|
||||
'filters.task_ids' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'filters.task_ids.*' => [
|
||||
'string',
|
||||
$this->idOrNoneExistsInOrganization(Task::class),
|
||||
],
|
||||
// Filter by tag IDs, tag IDs are OR combined, "none" matches entries without tags
|
||||
'filters.tag_ids' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'filters.tag_ids.*' => [
|
||||
'string',
|
||||
$this->idOrNoneExistsInOrganization(Tag::class),
|
||||
],
|
||||
'filters.tag_match_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TagMatchType::class),
|
||||
],
|
||||
// Filter by client IDs, client IDs are OR combined, "none" matches entries without a client
|
||||
'filters.client_ids' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'filters.client_ids.*' => [
|
||||
'string',
|
||||
$this->idOrNoneExistsInOrganization(Client::class),
|
||||
],
|
||||
// Filter by billable status, null means both
|
||||
'filters.billable' => [
|
||||
'nullable',
|
||||
'boolean',
|
||||
],
|
||||
// Filter by time entry type, null means both
|
||||
'filters.time_entry_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TimeEntryType::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<Project|Task|Tag|Client> $modelClass
|
||||
*/
|
||||
private function idOrNoneExistsInOrganization(string $modelClass): \Closure
|
||||
{
|
||||
return function (string $attribute, mixed $value, \Closure $fail) use ($modelClass): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make($modelClass, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project|Task|Tag|Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
};
|
||||
}
|
||||
|
||||
public function getFilters(): GoalFiltersDto
|
||||
{
|
||||
$filters = new GoalFiltersDto;
|
||||
$filters->setMemberIds($this->input('filters.member_ids'));
|
||||
$filters->setProjectIds($this->input('filters.project_ids'));
|
||||
$filters->setTaskIds($this->input('filters.task_ids'));
|
||||
$filters->setTagIds($this->input('filters.tag_ids'));
|
||||
$filters->tagMatchType = $this->input('filters.tag_match_type') !== null ? TagMatchType::from($this->input('filters.tag_match_type')) : null;
|
||||
$filters->setClientIds($this->input('filters.client_ids'));
|
||||
$filters->billable = $this->input('filters.billable') !== null ? (bool) $this->input('filters.billable') : null;
|
||||
$filters->timeEntryType = $this->input('filters.time_entry_type') !== null ? TimeEntryType::from($this->input('filters.time_entry_type')) : null;
|
||||
|
||||
return $filters;
|
||||
}
|
||||
}
|
||||
61
app/Http/Requests/V1/Goal/GoalIndexRequest.php
Normal file
61
app/Http/Requests/V1/Goal/GoalIndexRequest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Goal;
|
||||
|
||||
use App\Enums\GoalType;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class GoalIndexRequest extends BaseFormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// Only return goals of this type (personal, organization)
|
||||
'type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(GoalType::class),
|
||||
],
|
||||
// Filter by archived status, "true" only archived, "false" only not archived (default), "all" both
|
||||
'archived' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'in:true,false,all',
|
||||
],
|
||||
'page' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getType(): ?GoalType
|
||||
{
|
||||
if ($this->input('type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return GoalType::from($this->input('type'));
|
||||
}
|
||||
|
||||
public function getArchivedFilter(): string
|
||||
{
|
||||
return (string) $this->input('archived', 'false');
|
||||
}
|
||||
}
|
||||
167
app/Http/Requests/V1/Goal/GoalStoreRequest.php
Normal file
167
app/Http/Requests/V1/Goal/GoalStoreRequest.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Goal;
|
||||
|
||||
use App\Enums\GoalComparison;
|
||||
use App\Enums\GoalPeriod;
|
||||
use App\Enums\GoalType;
|
||||
use App\Enums\Role;
|
||||
use App\Enums\Weekday;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\In;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class GoalStoreRequest extends BaseFormRequest
|
||||
{
|
||||
use GoalFilterRules;
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule|LegacyValidationRule|In|\Closure>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$memberExistsInOrganization = ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Member> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization')
|
||||
->where('role', '!=', Role::Placeholder->value);
|
||||
})->uuid();
|
||||
|
||||
return array_merge([
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
// personal: a goal for yourself that nobody else can see, organization: a goal of the organization for a member or for every member. Organization goals require the team goals extension.
|
||||
'type' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::enum(GoalType::class),
|
||||
],
|
||||
// Whether the goal is reached when "at least" or "less than" the target time is tracked in the period
|
||||
'comparison' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::enum(GoalComparison::class),
|
||||
],
|
||||
// Target time in seconds
|
||||
'target_seconds' => [
|
||||
'required',
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:2147483647',
|
||||
],
|
||||
// The recurring time frame in which the target has to be reached
|
||||
'period' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::enum(GoalPeriod::class),
|
||||
],
|
||||
// ID of the member whose time entries count towards the goal, defaults to the current member. Send null for a goal that counts every member (organization goals only).
|
||||
'member_id' => $this->input('type') === GoalType::Personal->value
|
||||
// A personal goal is always for the current member, it can not count every member
|
||||
? [
|
||||
'sometimes',
|
||||
'filled',
|
||||
'string',
|
||||
Rule::in([$this->currentMemberId()]),
|
||||
]
|
||||
: [
|
||||
'nullable',
|
||||
'string',
|
||||
$memberExistsInOrganization,
|
||||
],
|
||||
// Timezone that defines the periods of the goal, defaults to the timezone of the current user
|
||||
'timezone' => [
|
||||
'nullable',
|
||||
'timezone:all',
|
||||
],
|
||||
// Week start that defines weekly periods, defaults to the week start of the current user
|
||||
'week_start' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(Weekday::class),
|
||||
],
|
||||
], $this->filterRules(
|
||||
// Without a member_id the goal is for the current member
|
||||
$this->has('member_id') && $this->input('member_id') === null,
|
||||
));
|
||||
}
|
||||
|
||||
private function currentMemberId(): ?string
|
||||
{
|
||||
/** @var string|null $memberId */
|
||||
$memberId = Member::query()
|
||||
->whereBelongsTo($this->organization, 'organization')
|
||||
->where('user_id', $this->user()?->getKey())
|
||||
->value('id');
|
||||
|
||||
return $memberId;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return (string) $this->input('name');
|
||||
}
|
||||
|
||||
public function getType(): GoalType
|
||||
{
|
||||
return GoalType::from($this->input('type'));
|
||||
}
|
||||
|
||||
public function getComparison(): GoalComparison
|
||||
{
|
||||
return GoalComparison::from($this->input('comparison'));
|
||||
}
|
||||
|
||||
public function getTargetSeconds(): int
|
||||
{
|
||||
return (int) $this->input('target_seconds');
|
||||
}
|
||||
|
||||
public function getPeriod(): GoalPeriod
|
||||
{
|
||||
return GoalPeriod::from($this->input('period'));
|
||||
}
|
||||
|
||||
public function hasMemberId(): bool
|
||||
{
|
||||
return $this->has('member_id');
|
||||
}
|
||||
|
||||
public function getMemberId(): ?string
|
||||
{
|
||||
return $this->input('member_id');
|
||||
}
|
||||
|
||||
public function getTimezone(): ?string
|
||||
{
|
||||
if (! $this->has('timezone') || $this->input('timezone') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $this->input('timezone');
|
||||
}
|
||||
|
||||
public function getWeekStart(): ?Weekday
|
||||
{
|
||||
if (! $this->has('week_start') || $this->input('week_start') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Weekday::from($this->input('week_start'));
|
||||
}
|
||||
}
|
||||
129
app/Http/Requests/V1/Goal/GoalUpdateRequest.php
Normal file
129
app/Http/Requests/V1/Goal/GoalUpdateRequest.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Goal;
|
||||
|
||||
use App\Enums\GoalComparison;
|
||||
use App\Enums\GoalPeriod;
|
||||
use App\Enums\Weekday;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Goal;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class GoalUpdateRequest extends BaseFormRequest
|
||||
{
|
||||
use GoalFilterRules;
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
* The type of a goal and the member it is for can not be changed after creation.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var Goal $goal */
|
||||
$goal = $this->route('goal');
|
||||
|
||||
return array_merge([
|
||||
'name' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
// Whether the goal is reached when "at least" or "less than" the target time is tracked in the period
|
||||
'comparison' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
Rule::enum(GoalComparison::class),
|
||||
],
|
||||
// Target time in seconds
|
||||
'target_seconds' => [
|
||||
'sometimes',
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:2147483647',
|
||||
],
|
||||
// The recurring time frame in which the target has to be reached
|
||||
'period' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
Rule::enum(GoalPeriod::class),
|
||||
],
|
||||
// Timezone that defines the periods of the goal
|
||||
'timezone' => [
|
||||
'sometimes',
|
||||
'timezone:all',
|
||||
],
|
||||
// Week start that defines weekly periods
|
||||
'week_start' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
Rule::enum(Weekday::class),
|
||||
],
|
||||
// Archived goals are hidden from the goal list by default, their progress is still calculated
|
||||
'is_archived' => [
|
||||
'sometimes',
|
||||
'boolean',
|
||||
],
|
||||
// Only allowed if it matches the current type of the goal
|
||||
'type' => [
|
||||
function (string $attribute, mixed $value, \Closure $fail) use ($goal): void {
|
||||
if ($value !== $goal->type->value) {
|
||||
$fail('The '.$attribute.' of a goal can not be changed.');
|
||||
}
|
||||
},
|
||||
],
|
||||
// Only allowed if it matches the current member of the goal
|
||||
'member_id' => [
|
||||
function (string $attribute, mixed $value, \Closure $fail) use ($goal): void {
|
||||
if ($value !== $goal->member_id) {
|
||||
$fail('The '.$attribute.' of a goal can not be changed.');
|
||||
}
|
||||
},
|
||||
],
|
||||
], $this->filterRules($goal->member_id === null));
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return (string) $this->input('name');
|
||||
}
|
||||
|
||||
public function getComparison(): GoalComparison
|
||||
{
|
||||
return GoalComparison::from($this->input('comparison'));
|
||||
}
|
||||
|
||||
public function getTargetSeconds(): int
|
||||
{
|
||||
return (int) $this->input('target_seconds');
|
||||
}
|
||||
|
||||
public function getPeriod(): GoalPeriod
|
||||
{
|
||||
return GoalPeriod::from($this->input('period'));
|
||||
}
|
||||
|
||||
public function getTimezone(): string
|
||||
{
|
||||
return (string) $this->input('timezone');
|
||||
}
|
||||
|
||||
public function getWeekStart(): Weekday
|
||||
{
|
||||
return Weekday::from($this->input('week_start'));
|
||||
}
|
||||
|
||||
public function getIsArchived(): bool
|
||||
{
|
||||
return (bool) $this->input('is_archived');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user