Add goals feature for personal goals with filters

This commit is contained in:
Gregor Vostrak
2026-10-02 12:59:44 +02:00
parent 349623d537
commit f9519d0d5a
83 changed files with 7702 additions and 105 deletions

View File

@@ -43,6 +43,15 @@ class BillingContract
return null;
}
/**
* The maximum number of goals the organization can have; null means unlimited.
* Archived goals count toward the limit.
*/
public function getGoalLimit(Organization $organization): ?int
{
return null;
}
/**
* Check if the organization is blocked
* A blocked organization is an organization that has more than 1 non-placeholder member but no subscription/trial

View File

@@ -8,6 +8,7 @@ use App\Enums\Role;
use App\Events\BeforeOrganizationDeletion;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Models\Client;
use App\Models\Goal;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
@@ -76,6 +77,9 @@ class DeletionService
// Delete all reports
Report::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all goals
Goal::query()->whereBelongsTo($organization, 'organization')->delete();
// Reset the current organization
$organization->owner()
->where('current_team_id', $organization->getKey())

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Service\Dto;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryType;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Persisted filter set of a goal. Defines which time entries count towards the goal.
* A null value means "no restriction" for that dimension. The ID setters store an empty array as null,
* so "no restriction" has a single representation.
*/
class GoalFiltersDto implements Castable
{
/**
* Members whose time entries count. Only used by goals that count every member, null means every member.
*
* @var Collection<int, string>|null
*/
public ?Collection $memberIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $projectIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $taskIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $tagIds = null;
public ?TagMatchType $tagMatchType = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $clientIds = null;
public ?bool $billable = null;
public ?TimeEntryType $timeEntryType = null;
/**
* Get the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
* @return CastsAttributes<GoalFiltersDto, GoalFiltersDto>
*/
public static function castUsing(array $arguments): CastsAttributes
{
return new class implements CastsAttributes
{
private const array REQUIRED_PROPERTIES = [
'memberIds',
'projectIds',
'taskIds',
'tagIds',
'tagMatchType',
'clientIds',
'billable',
'timeEntryType',
];
public function get(Model $model, string $key, mixed $value, array $attributes): GoalFiltersDto
{
if (! is_string($value)) {
throw new \InvalidArgumentException('The given value is not a string');
}
$data = json_decode($value, false);
if (! is_object($data)) {
throw new \InvalidArgumentException('The given value is not a JSON object string');
}
foreach (self::REQUIRED_PROPERTIES as $property) {
if (! property_exists($data, $property)) {
throw new \InvalidArgumentException('The given JSON string does not contain the required property "'.$property.'"');
}
}
$dto = new GoalFiltersDto;
$dto->setMemberIds(isset($data->memberIds) ? (array) $data->memberIds : null);
$dto->setProjectIds(isset($data->projectIds) ? (array) $data->projectIds : null);
$dto->setTaskIds(isset($data->taskIds) ? (array) $data->taskIds : null);
$dto->setTagIds(isset($data->tagIds) ? (array) $data->tagIds : null);
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
$dto->setClientIds(isset($data->clientIds) ? (array) $data->clientIds : null);
$dto->billable = isset($data->billable) ? (bool) $data->billable : null;
$dto->timeEntryType = isset($data->timeEntryType) ? TimeEntryType::from($data->timeEntryType) : null;
return $dto;
}
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (! ($value instanceof GoalFiltersDto)) {
throw new \InvalidArgumentException('The given value is not an instance of GoalFiltersDto');
}
$data = (object) [
'memberIds' => $value->memberIds?->toArray(),
'projectIds' => $value->projectIds?->toArray(),
'taskIds' => $value->taskIds?->toArray(),
'tagIds' => $value->tagIds?->toArray(),
'tagMatchType' => $value->tagMatchType?->value,
'clientIds' => $value->clientIds?->toArray(),
'billable' => $value->billable,
'timeEntryType' => $value->timeEntryType?->value,
];
$jsonString = json_encode($data);
if ($jsonString === false) {
throw new \InvalidArgumentException('Could not encode the given data to a JSON string');
}
return $jsonString;
}
};
}
/**
* @param array<mixed>|null $memberIds
*/
public function setMemberIds(?array $memberIds): void
{
$this->memberIds = $memberIds !== null && count($memberIds) > 0 ? TimeEntryFilter::idArrayToCollection($memberIds) : null;
}
/**
* @param array<mixed>|null $projectIds
*/
public function setProjectIds(?array $projectIds): void
{
$this->projectIds = $projectIds !== null && count($projectIds) > 0 ? TimeEntryFilter::idArrayToCollection($projectIds) : null;
}
/**
* @param array<mixed>|null $taskIds
*/
public function setTaskIds(?array $taskIds): void
{
$this->taskIds = $taskIds !== null && count($taskIds) > 0 ? TimeEntryFilter::idArrayToCollection($taskIds) : null;
}
/**
* @param array<mixed>|null $tagIds
*/
public function setTagIds(?array $tagIds): void
{
$this->tagIds = $tagIds !== null && count($tagIds) > 0 ? TimeEntryFilter::idArrayToCollection($tagIds) : null;
}
/**
* @param array<mixed>|null $clientIds
*/
public function setClientIds(?array $clientIds): void
{
$this->clientIds = $clientIds !== null && count($clientIds) > 0 ? TimeEntryFilter::idArrayToCollection($clientIds) : null;
}
}

View File

@@ -16,7 +16,6 @@ use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use InvalidArgumentException;
class ReportPropertiesDto implements Castable
@@ -117,13 +116,13 @@ class ReportPropertiesDto implements Castable
$dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null;
$dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null;
$dto->active = $data->active;
$dto->memberIds = $data->memberIds !== null ? ReportPropertiesDto::idArrayToCollection($data->memberIds) : null;
$dto->memberIds = $data->memberIds !== null ? TimeEntryFilter::idArrayToCollection($data->memberIds) : null;
$dto->billable = $data->billable;
$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->clientIds = $data->clientIds !== null ? TimeEntryFilter::idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? TimeEntryFilter::idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? TimeEntryFilter::idArrayToCollection($data->tagIds) : null;
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
$dto->taskIds = $data->taskIds ? TimeEntryFilter::idArrayToCollection($data->taskIds) : null;
$dto->group = TimeEntryAggregationType::from($data->group);
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
@@ -180,32 +179,12 @@ class ReportPropertiesDto implements Castable
};
}
/**
* @param array<mixed> $ids
* @return Collection<int, string>
*/
public static function idArrayToCollection(array $ids): Collection
{
$collection = new Collection;
foreach ($ids as $id) {
if (! is_string($id)) {
throw new InvalidArgumentException('The given ID is not a string');
}
if ($id !== TimeEntryFilter::NONE_VALUE && ! Str::isUuid($id)) {
throw new InvalidArgumentException('The given ID is not a valid UUID');
}
$collection->push($id);
}
return $collection;
}
/**
* @param array<mixed>|null $memberIds
*/
public function setMemberIds(?array $memberIds): void
{
$this->memberIds = $memberIds !== null ? ReportPropertiesDto::idArrayToCollection($memberIds) : null;
$this->memberIds = $memberIds !== null ? TimeEntryFilter::idArrayToCollection($memberIds) : null;
}
/**
@@ -213,7 +192,7 @@ class ReportPropertiesDto implements Castable
*/
public function setClientIds(?array $clientIds): void
{
$this->clientIds = $clientIds !== null ? ReportPropertiesDto::idArrayToCollection($clientIds) : null;
$this->clientIds = $clientIds !== null ? TimeEntryFilter::idArrayToCollection($clientIds) : null;
}
/**
@@ -221,7 +200,7 @@ class ReportPropertiesDto implements Castable
*/
public function setProjectIds(?array $projectIds): void
{
$this->projectIds = $projectIds !== null ? ReportPropertiesDto::idArrayToCollection($projectIds) : null;
$this->projectIds = $projectIds !== null ? TimeEntryFilter::idArrayToCollection($projectIds) : null;
}
/**
@@ -229,7 +208,7 @@ class ReportPropertiesDto implements Castable
*/
public function setTagIds(?array $tagIds): void
{
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
$this->tagIds = $tagIds !== null ? TimeEntryFilter::idArrayToCollection($tagIds) : null;
}
public function setTagMatchType(?TagMatchType $tagMatchType): void
@@ -242,6 +221,6 @@ class ReportPropertiesDto implements Castable
*/
public function setTaskIds(?array $taskIds): void
{
$this->taskIds = $taskIds !== null ? ReportPropertiesDto::idArrayToCollection($taskIds) : null;
$this->taskIds = $taskIds !== null ? TimeEntryFilter::idArrayToCollection($taskIds) : null;
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\GoalStatus;
use App\Models\Goal;
use App\Models\TimeEntry;
use Illuminate\Support\Carbon;
class GoalProgressService
{
/**
* Bounds of the period that contains $date, in UTC. The period is calculated in the timezone of the goal
* and respects the week start of the goal. The start is inclusive, the end exclusive.
*
* @return array{0: Carbon, 1: Carbon}
*/
public function getPeriodBounds(Goal $goal, Carbon $date): array
{
$dateInTimezone = $date->copy()->setTimezone($goal->timezone);
$start = match ($goal->period) {
GoalPeriod::Day => $dateInTimezone->copy()->startOfDay(),
GoalPeriod::Week => $dateInTimezone->copy()->startOfWeek($goal->week_start->carbonWeekDay()),
GoalPeriod::Month => $dateInTimezone->copy()->startOfMonth(),
};
$end = match ($goal->period) {
GoalPeriod::Day => $start->copy()->addDay(),
GoalPeriod::Week => $start->copy()->addWeek(),
GoalPeriod::Month => $start->copy()->addMonth(),
};
return [$start->utc(), $end->utc()];
}
/**
* Seconds tracked in the given period that count towards the goal.
* Counts the time entries of the member of the goal, or of every member for goals without a member.
* Time entries are assigned to the period their start is in, like in the reporting.
* A running time entry counts with its elapsed time up to now.
*
* @param Carbon $periodStart Start of the period (inclusive)
* @param Carbon $periodEnd End of the period (exclusive)
*/
public function getProgress(Goal $goal, Carbon $periodStart, Carbon $periodEnd): int
{
$filters = $goal->filters;
$query = TimeEntry::query()
->where('organization_id', '=', $goal->organization_id);
if ($goal->member_id !== null) {
$query->where('member_id', '=', $goal->member_id);
}
$timeEntryFilter = new TimeEntryFilter($query);
$timeEntryFilter
->addStart($periodStart)
->addEnd($periodEnd)
->addProjectIdsFilter($filters->projectIds?->toArray())
->addTaskIdsFilter($filters->taskIds?->toArray())
->addTagIdsFilter($filters->tagIds?->toArray(), $filters->tagMatchType)
->addClientIdsFilter($filters->clientIds?->toArray())
->addBillable($filters->billable)
->addType($filters->timeEntryType);
if ($goal->member_id === null) {
$timeEntryFilter->addMemberIdsFilter($filters->memberIds?->toArray());
}
/** @var object{tracked_seconds: int|float|string|null} $row */
$row = $timeEntryFilter->get()
->selectRaw('coalesce(round(sum(extract(epoch from (coalesce("end", ?::timestamp) - "start")))), 0) as tracked_seconds', [Carbon::now()])
->toBase()
->first();
return max(0, (int) $row->tracked_seconds);
}
public function getStatus(Goal $goal, int $trackedSeconds): GoalStatus
{
return match ($goal->comparison) {
GoalComparison::AtLeast => $trackedSeconds >= $goal->target_seconds
? GoalStatus::Achieved
: GoalStatus::InProgress,
GoalComparison::LessThan => $trackedSeconds < $goal->target_seconds
? GoalStatus::OnTrack
: GoalStatus::Exceeded,
};
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\GoalType;
use App\Models\Goal;
use App\Models\Member;
use Illuminate\Database\Eloquent\Builder;
/**
* Decides which goals a member can reach. The `goals:*:own` permissions gate the feature; this decides the rows.
* Core only has personal goals, reachable by their member alone (not even admins or owners).
* The team goals extension rebinds this to add organization goals.
*/
class GoalsContract
{
/**
* Whether $actor is allowed to create a goal of the given type for the given member.
*
* @param string|null $targetMemberId Member whose time entries count, null for every member (organization goals only)
*/
public function canCreateGoal(Member $actor, GoalType $type, ?string $targetMemberId): bool
{
return $type === GoalType::Personal && $targetMemberId === $actor->getKey();
}
/**
* Whether $actor is allowed to see the goal (including its progress).
*/
public function canViewGoal(Member $actor, Goal $goal): bool
{
return $goal->isPersonalGoalOf($actor);
}
/**
* Whether $actor is allowed to update the goal (including its filters and archiving it).
*/
public function canUpdateGoal(Member $actor, Goal $goal): bool
{
return $goal->isPersonalGoalOf($actor);
}
/**
* Whether $actor is allowed to delete the goal.
*/
public function canDeleteGoal(Member $actor, Goal $goal): bool
{
return $goal->isPersonalGoalOf($actor);
}
/**
* Restrict the query to the goals that $actor is allowed to see.
*
* @param Builder<Goal> $query
* @return Builder<Goal>
*/
public function scopeVisibleGoals(Builder $query, Member $actor): Builder
{
return $query->where('type', '=', GoalType::Personal->value)
->where('member_id', '=', $actor->getKey());
}
}

View File

@@ -14,6 +14,7 @@ use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Models\Goal;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -109,6 +110,12 @@ class MemberService
}
}
// A goal is deleted together with the member it is for, it is not part of the "still in use" check
Goal::query()
->whereBelongsTo($organization, 'organization')
->where('member_id', '=', $member->getKey())
->delete();
$member->delete();
if ($isPlaceholder) {
@@ -181,6 +188,30 @@ class MemberService
->whereBelongsToOrganization($organization)
->whereBelongsTo($fromMember, 'member')
->delete();
// Goals: the goal belongs to the person it is for, like time entries it moves along
Goal::query()
->whereBelongsTo($organization, 'organization')
->where('member_id', '=', $fromMember->getKey())
->update([
'member_id' => $toMember->getKey(),
]);
// Goal member filters: the time entries of $fromMember now belong to $toMember, so the filters follow them
$goalsWithMemberFilter = Goal::query()
->whereBelongsTo($organization, 'organization')
->whereJsonContains('filters->memberIds', $fromMember->getKey())
->get();
foreach ($goalsWithMemberFilter as $goal) {
$filters = $goal->filters;
$memberIds = $filters->memberIds?->map(
fn (string $memberId): string => $memberId === $fromMember->getKey() ? $toMember->getKey() : $memberId
);
// $toMember may already be in the filter
$filters->setMemberIds($memberIds?->unique()->values()->all());
$goal->filters = $filters;
$goal->save();
}
}
/**

View File

@@ -75,6 +75,14 @@ class PermissionStore
'reports:create',
'reports:update',
'reports:delete',
'goals:view:own',
'goals:create:own',
'goals:update:own',
'goals:delete:own',
'goals:view:organization-type',
'goals:create:organization-type',
'goals:update:organization-type',
'goals:delete:organization-type',
'invoices:view',
'invoices:create',
'invoices:update',
@@ -146,6 +154,14 @@ class PermissionStore
'reports:create',
'reports:update',
'reports:delete',
'goals:view:own',
'goals:create:own',
'goals:update:own',
'goals:delete:own',
'goals:view:organization-type',
'goals:create:organization-type',
'goals:update:organization-type',
'goals:delete:organization-type',
'invoices:view',
'invoices:create',
'invoices:update',
@@ -206,6 +222,14 @@ class PermissionStore
'reports:create',
'reports:update',
'reports:delete',
'goals:view:own',
'goals:create:own',
'goals:update:own',
'goals:delete:own',
'goals:view:organization-type',
'goals:create:organization-type',
'goals:update:organization-type',
'goals:delete:organization-type',
'invoices:view',
'invoices:create',
'invoices:update',
@@ -233,6 +257,10 @@ class PermissionStore
'time-entries:update:own',
'time-entries:delete:own',
'organizations:view',
'goals:view:own',
'goals:create:own',
'goals:update:own',
'goals:delete:own',
],
'description' => 'Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.',
],

View File

@@ -10,7 +10,9 @@ use App\Models\Member;
use App\Models\TimeEntry;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class TimeEntryFilter
{
@@ -29,6 +31,26 @@ class TimeEntryFilter
$this->builder = $builder;
}
/**
* @param array<mixed> $ids
* @return Collection<int, string>
*/
public static function idArrayToCollection(array $ids): Collection
{
$collection = new Collection;
foreach ($ids as $id) {
if (! is_string($id)) {
throw new \InvalidArgumentException('The given ID is not a string');
}
if ($id !== self::NONE_VALUE && ! Str::isUuid($id)) {
throw new \InvalidArgumentException('The given ID is not a valid UUID');
}
$collection->push($id);
}
return $collection;
}
public function addEndFilter(?string $dateTime): self
{
if ($dateTime === null) {