mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-09 14:23:19 +01:00
Compare commits
26 Commits
feature/ex
...
feature/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
418628d3fb | ||
|
|
1256fa61bd | ||
|
|
d6f80e23fb | ||
|
|
c994345de9 | ||
|
|
e56a659723 | ||
|
|
c3529e4373 | ||
|
|
81c923644d | ||
|
|
689b2e93e1 | ||
|
|
63ca886154 | ||
|
|
5010a5be3a | ||
|
|
b05f275fe8 | ||
|
|
f01a4111b6 | ||
|
|
4a2f78931a | ||
|
|
ed2f19f58b | ||
|
|
fde944a84f | ||
|
|
34273a4863 | ||
|
|
6fe721fc26 | ||
|
|
f921452419 | ||
|
|
30a90f80e0 | ||
|
|
135984f9ef | ||
|
|
af54b0db73 | ||
|
|
3fc2cec751 | ||
|
|
32ac8841bc | ||
|
|
1582e6f4c3 | ||
|
|
01281d80d0 | ||
|
|
bb5a7fb9f9 |
7
.github/workflows/phpunit-extensions.yml
vendored
7
.github/workflows/phpunit-extensions.yml
vendored
@@ -5,7 +5,7 @@ permissions:
|
||||
jobs:
|
||||
phpunit-extensions:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
matrix:
|
||||
postgres_version: [ 15, 16, 17 ]
|
||||
@@ -132,5 +132,8 @@ jobs:
|
||||
php artisan key:generate
|
||||
php artisan passport:keys
|
||||
|
||||
- name: "Run PHPUnit"
|
||||
- name: "Run PHPUnit (extensions)"
|
||||
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure
|
||||
|
||||
- name: "Run PHPUnit (core)"
|
||||
run: php artisan test --testsuite=Unit,Feature --stop-on-failure
|
||||
|
||||
22
app/Enums/GoalComparison.php
Normal file
22
app/Enums/GoalComparison.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum GoalComparison: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
/**
|
||||
* The goal is reached when at least the target amount of time was tracked in the period.
|
||||
*/
|
||||
case AtLeast = 'at_least';
|
||||
|
||||
/**
|
||||
* The goal is reached when less than the target amount of time was tracked in the period.
|
||||
*/
|
||||
case LessThan = 'less_than';
|
||||
}
|
||||
16
app/Enums/GoalPeriod.php
Normal file
16
app/Enums/GoalPeriod.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum GoalPeriod: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case Day = 'day';
|
||||
case Week = 'week';
|
||||
case Month = 'month';
|
||||
}
|
||||
32
app/Enums/GoalStatus.php
Normal file
32
app/Enums/GoalStatus.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum GoalStatus: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
/**
|
||||
* "At least" goal, the target has not been reached yet in the current period.
|
||||
*/
|
||||
case InProgress = 'in_progress';
|
||||
|
||||
/**
|
||||
* "At least" goal, the target has been reached in the current period.
|
||||
*/
|
||||
case Achieved = 'achieved';
|
||||
|
||||
/**
|
||||
* "Less than" goal, the tracked time is still below the limit.
|
||||
*/
|
||||
case OnTrack = 'on_track';
|
||||
|
||||
/**
|
||||
* "Less than" goal, the tracked time reached or exceeded the limit.
|
||||
*/
|
||||
case Exceeded = 'exceeded';
|
||||
}
|
||||
24
app/Enums/GoalType.php
Normal file
24
app/Enums/GoalType.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum GoalType: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
/**
|
||||
* A goal a member set for themselves. Only that member can see and manage it, no permission reaches it.
|
||||
*/
|
||||
case Personal = 'personal';
|
||||
|
||||
/**
|
||||
* A goal the organization set for a member or for the whole organization.
|
||||
* Managed by the members that may manage goals, visible to them and to the member the goal is for.
|
||||
* Requires the team goals extension.
|
||||
*/
|
||||
case Organization = 'organization';
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Extensions\Scramble;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
|
||||
use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
|
||||
use Dedoc\Scramble\Support\Generator\Response;
|
||||
use Dedoc\Scramble\Support\Generator\Schema;
|
||||
use Dedoc\Scramble\Support\Generator\Types\ArrayType;
|
||||
use Dedoc\Scramble\Support\Generator\Types\BooleanType;
|
||||
use Dedoc\Scramble\Support\Generator\Types\IntegerType;
|
||||
use Dedoc\Scramble\Support\Generator\Types\ObjectType as OpenApiObjectType;
|
||||
use Dedoc\Scramble\Support\Generator\Types\StringType;
|
||||
use Dedoc\Scramble\Support\Type\Generic;
|
||||
use Dedoc\Scramble\Support\Type\ObjectType;
|
||||
use Dedoc\Scramble\Support\Type\Type;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
|
||||
{
|
||||
public function shouldHandle(Type $type): bool
|
||||
{
|
||||
return $type instanceof ObjectType
|
||||
&& $type->isInstanceOf(PaginatedResourceCollection::class);
|
||||
}
|
||||
|
||||
public function toSchema(Type $type): ?OpenApiObjectType
|
||||
{
|
||||
/** @var Type|null $collectingClassType */
|
||||
$collectingClassType = $type->templateTypes[0] ?? null;
|
||||
|
||||
if (! $collectingClassType instanceof ObjectType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $collectingClassType->isInstanceOf(JsonResource::class) && ! $collectingClassType->isInstanceOf(Model::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$collectingType = $this->openApiTransformer->transform($collectingClassType);
|
||||
|
||||
$newType = new OpenApiObjectType;
|
||||
$newType->addProperty('data', (new ArrayType)->setItems($collectingType));
|
||||
if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) {
|
||||
$newType->addProperty(
|
||||
'meta',
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
||||
->setRequired(['total'])
|
||||
);
|
||||
$newType->setRequired(['data', 'meta']);
|
||||
} else {
|
||||
$newType->addProperty(
|
||||
'links',
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('first', (new StringType)->nullable(true))
|
||||
->addProperty('last', (new StringType)->nullable(true))
|
||||
->addProperty('prev', (new StringType)->nullable(true))
|
||||
->addProperty('next', (new StringType)->nullable(true))
|
||||
->setRequired(['first', 'last', 'prev', 'next'])
|
||||
);
|
||||
$newType->addProperty(
|
||||
'meta',
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('current_page', new IntegerType)
|
||||
->addProperty('from', (new IntegerType)->nullable(true))
|
||||
->addProperty('last_page', new IntegerType)
|
||||
->addProperty('links', (new ArrayType)->setItems(
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('url', (new StringType)->nullable(true))
|
||||
->addProperty('label', new StringType)
|
||||
->addProperty('active', new BooleanType)
|
||||
->setRequired(['url', 'label', 'active'])
|
||||
)->setDescription('Generated paginator links.'))
|
||||
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
|
||||
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
|
||||
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
|
||||
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
||||
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
|
||||
);
|
||||
$newType->setRequired(['data', 'links', 'meta']);
|
||||
}
|
||||
|
||||
return $newType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Generic $type
|
||||
*/
|
||||
public function toResponse(Type $type): ?Response
|
||||
{
|
||||
/** @var ObjectType|null $collectingClassType */
|
||||
$collectingClassType = $type->templateTypes[0] ?? null;
|
||||
if (! $collectingClassType instanceof ObjectType) {
|
||||
return null;
|
||||
}
|
||||
$type = $this->toSchema($type);
|
||||
|
||||
return Response::make(200)
|
||||
->description('Paginated set of `'.$this->components->uniqueSchemaName($collectingClassType->name).'`')
|
||||
->setContent('application/json', Schema::fromType($type));
|
||||
}
|
||||
}
|
||||
174
app/Filament/Resources/GoalResource.php
Normal file
174
app/Filament/Resources/GoalResource.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Enums\GoalComparison;
|
||||
use App\Enums\GoalPeriod;
|
||||
use App\Enums\GoalType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Filament\Resources\GoalResource\Pages;
|
||||
use App\Models\Goal;
|
||||
use App\Service\Dto\GoalFiltersDto;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
|
||||
|
||||
class GoalResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Goal::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-viewfinder-circle';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
|
||||
|
||||
protected static ?int $navigationSort = 8;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->columns(1)
|
||||
->components([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\Select::make('type')
|
||||
->label('Type')
|
||||
->options(collect(GoalType::cases())->mapWithKeys(fn (GoalType $type): array => [$type->value => $type->name])->all())
|
||||
->disabled()
|
||||
->required(),
|
||||
Forms\Components\Select::make('comparison')
|
||||
->label('Comparison')
|
||||
->options(collect(GoalComparison::cases())->mapWithKeys(fn (GoalComparison $comparison): array => [$comparison->value => $comparison->name])->all())
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('target_seconds')
|
||||
->label('Target (seconds)')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->required(),
|
||||
Forms\Components\Select::make('period')
|
||||
->label('Period')
|
||||
->options(collect(GoalPeriod::cases())->mapWithKeys(fn (GoalPeriod $period): array => [$period->value => $period->name])->all())
|
||||
->required(),
|
||||
Forms\Components\Select::make('timezone')
|
||||
->label('Timezone')
|
||||
->options(fn (): array => collect(\DateTimeZone::listIdentifiers())->mapWithKeys(fn (string $timezone): array => [$timezone => $timezone])->all())
|
||||
->searchable()
|
||||
->required(),
|
||||
Forms\Components\Select::make('week_start')
|
||||
->label('Week start')
|
||||
->options(collect(Weekday::cases())->mapWithKeys(fn (Weekday $weekday): array => [$weekday->value => $weekday->name])->all())
|
||||
->required(),
|
||||
DateTimePicker::make('archived_at')
|
||||
->label('Archived At'),
|
||||
Forms\Components\Select::make('organization_id')
|
||||
->label('Organization')
|
||||
->relationship(name: 'organization', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
->disabled()
|
||||
->required(),
|
||||
Forms\Components\Select::make('member_id')
|
||||
->label('Member (empty = every member)')
|
||||
->relationship(name: 'member', titleAttribute: 'id')
|
||||
->disabled(),
|
||||
PrettyJsonField::make('filters')
|
||||
->formatStateUsing(function (GoalFiltersDto $state, Goal $record): string {
|
||||
return $record->getRawOriginal('filters');
|
||||
})
|
||||
->disabled(),
|
||||
DateTimePicker::make('created_at')
|
||||
->label('Created At')
|
||||
->hiddenOn(['create'])
|
||||
->disabled(),
|
||||
DateTimePicker::make('updated_at')
|
||||
->label('Updated At')
|
||||
->hiddenOn(['create'])
|
||||
->disabled(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('comparison')
|
||||
->sortable(),
|
||||
TextColumn::make('target_seconds')
|
||||
->label('Target')
|
||||
->formatStateUsing(fn (int $state): string => sprintf('%dh %02dm', intdiv($state, 3600), intdiv($state % 3600, 60)))
|
||||
->sortable(),
|
||||
TextColumn::make('period')
|
||||
->sortable(),
|
||||
TextColumn::make('type')
|
||||
->sortable(),
|
||||
TextColumn::make('member.user.email')
|
||||
->label('Member')
|
||||
->placeholder('Every member')
|
||||
->searchable(),
|
||||
TextColumn::make('archived_at')
|
||||
->dateTime()
|
||||
->placeholder('Not archived')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('organization.name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
SelectFilter::make('type')
|
||||
->label('Type')
|
||||
->options(collect(GoalType::cases())->mapWithKeys(fn (GoalType $type): array => [$type->value => $type->name])->all()),
|
||||
SelectFilter::make('organization')
|
||||
->label('Organization')
|
||||
->relationship('organization', 'name')
|
||||
->searchable(),
|
||||
SelectFilter::make('organization_id')
|
||||
->label('Organization ID')
|
||||
->relationship('organization', 'id')
|
||||
->searchable(),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListGoals::route('/'),
|
||||
'edit' => Pages\EditGoal::route('/{record}/edit'),
|
||||
'view' => Pages\ViewGoal::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Filament/Resources/GoalResource/Pages/EditGoal.php
Normal file
22
app/Filament/Resources/GoalResource/Pages/EditGoal.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\GoalResource\Pages;
|
||||
|
||||
use App\Filament\Resources\GoalResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditGoal extends EditRecord
|
||||
{
|
||||
protected static string $resource = GoalResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->icon('heroicon-m-trash'),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/GoalResource/Pages/ListGoals.php
Normal file
19
app/Filament/Resources/GoalResource/Pages/ListGoals.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\GoalResource\Pages;
|
||||
|
||||
use App\Filament\Resources\GoalResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListGoals extends ListRecords
|
||||
{
|
||||
protected static string $resource = GoalResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Filament/Resources/GoalResource/Pages/ViewGoal.php
Normal file
22
app/Filament/Resources/GoalResource/Pages/ViewGoal.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\GoalResource\Pages;
|
||||
|
||||
use App\Filament\Resources\GoalResource;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewGoal extends ViewRecord
|
||||
{
|
||||
protected static string $resource = GoalResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
EditAction::make('edit')
|
||||
->icon('heroicon-s-pencil'),
|
||||
];
|
||||
}
|
||||
}
|
||||
21
app/Http/Controllers/Api/FallbackController.php
Normal file
21
app/Http/Controllers/Api/FallbackController.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Dedoc\Scramble\Attributes\ExcludeAllRoutesFromDocs;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Fallback for unknown /api/* routes, to prevent a rendered HTML page
|
||||
*/
|
||||
#[ExcludeAllRoutesFromDocs]
|
||||
class FallbackController extends Controller
|
||||
{
|
||||
public function __invoke(): never
|
||||
{
|
||||
throw new NotFoundHttpException('API resource not found');
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,6 @@ class ClientController extends Controller
|
||||
/**
|
||||
* Get clients
|
||||
*
|
||||
* @return ClientCollection<ClientResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getClients
|
||||
|
||||
241
app/Http/Controllers/Api/V1/GoalController.php
Normal file
241
app/Http/Controllers/Api/V1/GoalController.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\GoalType;
|
||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||
use App\Http\Requests\V1\Goal\GoalIndexRequest;
|
||||
use App\Http\Requests\V1\Goal\GoalStoreRequest;
|
||||
use App\Http\Requests\V1\Goal\GoalUpdateRequest;
|
||||
use App\Http\Resources\V1\Goal\GoalCollection;
|
||||
use App\Http\Resources\V1\Goal\GoalResource;
|
||||
use App\Models\Goal;
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillingContract;
|
||||
use App\Service\GoalProgressService;
|
||||
use App\Service\GoalsContract;
|
||||
use App\Service\TimezoneService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class GoalController extends Controller
|
||||
{
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
protected function checkPermission(Organization $organization, string $permission, ?Goal $goal = null): void
|
||||
{
|
||||
parent::checkPermission($organization, $permission);
|
||||
if ($goal !== null) {
|
||||
$this->checkGoalBelongsToOrganization($organization, $goal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
private function checkGoalBelongsToOrganization(Organization $organization, Goal $goal): void
|
||||
{
|
||||
if ($goal->organization_id !== $organization->id) {
|
||||
throw new AuthorizationException('Goal does not belong to organization');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the goal limit provided by the BillingContract, if any. Archived goals count toward the limit.
|
||||
*
|
||||
* @throws FeatureIsNotAvailableInFreePlanApiException
|
||||
*/
|
||||
private function checkGoalLimit(Organization $organization): void
|
||||
{
|
||||
$limit = app(BillingContract::class)->getGoalLimit($organization);
|
||||
if ($limit === null) {
|
||||
return;
|
||||
}
|
||||
$goals = Goal::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->count();
|
||||
if ($goals >= $limit) {
|
||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get goals
|
||||
*
|
||||
* Returns the goals the current member is allowed to see, including the progress in the current period.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getGoals
|
||||
*/
|
||||
public function index(Organization $organization, GoalIndexRequest $request, GoalsContract $access, GoalProgressService $progressService): GoalCollection
|
||||
{
|
||||
// An organization goal is visible to the member it is for, who does not hold the :organization-type permission.
|
||||
// Either permission is enough, the access contract decides which goals come back.
|
||||
$this->checkAnyPermission($organization, ['goals:view:own', 'goals:view:organization-type']);
|
||||
$member = $this->member($organization);
|
||||
|
||||
$query = Goal::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->with(['member.user'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id');
|
||||
$query = $access->scopeVisibleGoals($query, $member);
|
||||
if ($request->getType() !== null) {
|
||||
$query->where('type', '=', $request->getType()->value);
|
||||
}
|
||||
if ($request->getArchivedFilter() === 'true') {
|
||||
$query->archived();
|
||||
} elseif ($request->getArchivedFilter() === 'false') {
|
||||
$query->notArchived();
|
||||
}
|
||||
|
||||
$goals = $query->paginate(config('app.pagination_per_page_default'));
|
||||
$progressByGoalId = $progressService->getCurrentProgressForGoals($goals->getCollection(), Carbon::now());
|
||||
|
||||
return new GoalCollection($goals, $progressByGoalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get goal
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getGoal
|
||||
*/
|
||||
public function show(Organization $organization, Goal $goal, GoalsContract $access, GoalProgressService $progressService): GoalResource
|
||||
{
|
||||
// Either permission is enough, see index
|
||||
$this->checkAnyPermission($organization, ['goals:view:own', 'goals:view:organization-type']);
|
||||
$this->checkGoalBelongsToOrganization($organization, $goal);
|
||||
$member = $this->member($organization);
|
||||
if (! $access->canViewGoal($member, $goal)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
$goal->load('member.user');
|
||||
|
||||
return new GoalResource($goal, $progressService->getCurrentProgress($goal, Carbon::now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create goal
|
||||
*
|
||||
* @throws AuthorizationException|FeatureIsNotAvailableInFreePlanApiException
|
||||
*
|
||||
* @operationId createGoal
|
||||
*/
|
||||
public function store(Organization $organization, GoalStoreRequest $request, GoalsContract $access, GoalProgressService $progressService): JsonResponse
|
||||
{
|
||||
if ($request->getType() === GoalType::Personal) {
|
||||
$this->checkPermission($organization, 'goals:create:own');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'goals:create:organization-type');
|
||||
}
|
||||
$member = $this->member($organization);
|
||||
|
||||
$type = $request->getType();
|
||||
$targetMemberId = $request->hasMemberId() ? $request->getMemberId() : $member->getKey();
|
||||
if (! $access->canCreateGoal($member, $type, $targetMemberId)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
$this->checkGoalLimit($organization);
|
||||
|
||||
$user = $member->user;
|
||||
$goal = new Goal;
|
||||
$goal->name = $request->getName();
|
||||
$goal->type = $type;
|
||||
$goal->comparison = $request->getComparison();
|
||||
$goal->target_seconds = $request->getTargetSeconds();
|
||||
$goal->period = $request->getPeriod();
|
||||
$goal->filters = $request->getFilters();
|
||||
$goal->timezone = $request->getTimezone() ?? app(TimezoneService::class)->getTimezoneFromUser($user)->getName();
|
||||
$goal->week_start = $request->getWeekStart() ?? $user->week_start;
|
||||
$goal->organization()->associate($organization);
|
||||
$goal->member_id = $targetMemberId;
|
||||
$goal->save();
|
||||
$goal->load('member.user');
|
||||
|
||||
return (new GoalResource($goal, $progressService->getCurrentProgress($goal, Carbon::now())))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update goal
|
||||
*
|
||||
* The type of a goal and the member it is for can not be changed after creation.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId updateGoal
|
||||
*/
|
||||
public function update(Organization $organization, Goal $goal, GoalUpdateRequest $request, GoalsContract $access, GoalProgressService $progressService): GoalResource
|
||||
{
|
||||
if ($goal->type === GoalType::Personal) {
|
||||
$this->checkPermission($organization, 'goals:update:own', $goal);
|
||||
} else {
|
||||
$this->checkPermission($organization, 'goals:update:organization-type', $goal);
|
||||
}
|
||||
$member = $this->member($organization);
|
||||
if (! $access->canUpdateGoal($member, $goal)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
if ($request->has('filters')) {
|
||||
$goal->filters = $request->getFilters();
|
||||
}
|
||||
|
||||
if ($request->has('name')) {
|
||||
$goal->name = $request->getName();
|
||||
}
|
||||
if ($request->has('comparison')) {
|
||||
$goal->comparison = $request->getComparison();
|
||||
}
|
||||
if ($request->has('target_seconds')) {
|
||||
$goal->target_seconds = $request->getTargetSeconds();
|
||||
}
|
||||
if ($request->has('period')) {
|
||||
$goal->period = $request->getPeriod();
|
||||
}
|
||||
if ($request->has('timezone')) {
|
||||
$goal->timezone = $request->getTimezone();
|
||||
}
|
||||
if ($request->has('week_start')) {
|
||||
$goal->week_start = $request->getWeekStart();
|
||||
}
|
||||
if ($request->has('is_archived')) {
|
||||
$goal->archived_at = $request->getIsArchived() ? Carbon::now() : null;
|
||||
}
|
||||
$goal->save();
|
||||
$goal->load('member.user');
|
||||
|
||||
return new GoalResource($goal, $progressService->getCurrentProgress($goal, Carbon::now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete goal
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId deleteGoal
|
||||
*/
|
||||
public function destroy(Organization $organization, Goal $goal, GoalsContract $access): JsonResponse
|
||||
{
|
||||
if ($goal->type === GoalType::Personal) {
|
||||
$this->checkPermission($organization, 'goals:delete:own', $goal);
|
||||
} else {
|
||||
$this->checkPermission($organization, 'goals:delete:organization-type', $goal);
|
||||
}
|
||||
$member = $this->member($organization);
|
||||
if (! $access->canDeleteGoal($member, $goal)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$goal->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
|
||||
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
|
||||
use App\Http\Resources\V1\Invitation\InvitationCollection;
|
||||
use App\Http\Resources\V1\Invitation\InvitationResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Service\InvitationService;
|
||||
@@ -30,8 +29,6 @@ class InvitationController extends Controller
|
||||
/**
|
||||
* List all invitations of an organization
|
||||
*
|
||||
* @return InvitationCollection<InvitationResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getInvitations
|
||||
@@ -88,6 +85,8 @@ class InvitationController extends Controller
|
||||
/**
|
||||
* Remove a pending invitation
|
||||
*
|
||||
* This revokes the invitation: the link in the invitation email stops working. Find the invitation ID with `GET /organizations/{organization}/invitations`.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId removeInvitation
|
||||
|
||||
@@ -48,8 +48,6 @@ class MemberController extends Controller
|
||||
/**
|
||||
* List all members of an organization
|
||||
*
|
||||
* @return MemberCollection<MemberResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getMembers
|
||||
@@ -145,6 +143,9 @@ class MemberController extends Controller
|
||||
/**
|
||||
* Merge one member into another
|
||||
*
|
||||
* Only placeholder members (for example people created by an import) can be merged. All time entries and other data of the placeholder
|
||||
* are reassigned to the member given in `member_id`, and the placeholder is removed. Find both member IDs with `GET /organizations/{organization}/members`.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
|
||||
* @throws Throwable
|
||||
|
||||
@@ -35,8 +35,6 @@ class ProjectController extends Controller
|
||||
/**
|
||||
* Get projects visible to the current user
|
||||
*
|
||||
* @return ProjectCollection<ProjectResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getProjects
|
||||
|
||||
@@ -36,8 +36,6 @@ class ProjectMemberController extends Controller
|
||||
/**
|
||||
* Get project members for project
|
||||
*
|
||||
* @return ProjectMemberCollection<ProjectMemberResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getProjectMembers
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Http\Requests\V1\Report\ReportStoreRequest;
|
||||
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
||||
use App\Http\Resources\V1\Report\DetailedReportResource;
|
||||
use App\Http\Resources\V1\Report\ReportCollection;
|
||||
use App\Http\Resources\V1\Report\ReportResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Report;
|
||||
use App\Service\Dto\ReportPropertiesDto;
|
||||
@@ -35,8 +34,6 @@ class ReportController extends Controller
|
||||
/**
|
||||
* Get reports
|
||||
*
|
||||
* @return ReportCollection<ReportResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getReports
|
||||
@@ -71,6 +68,8 @@ class ReportController extends Controller
|
||||
/**
|
||||
* Create report
|
||||
*
|
||||
* A report is a saved set of filters. Set `is_public` to `true` to share it: the response then contains the `shareable_link` that can be opened without logging in.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId createReport
|
||||
|
||||
@@ -29,8 +29,6 @@ class TagController extends Controller
|
||||
/**
|
||||
* Get tags
|
||||
*
|
||||
* @return TagCollection<TagResource>
|
||||
*
|
||||
* @operationId getTags
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
|
||||
@@ -51,8 +51,6 @@ class TaskController extends Controller
|
||||
/**
|
||||
* Get tasks
|
||||
*
|
||||
* @return TaskCollection<TaskResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getTasks
|
||||
|
||||
@@ -109,10 +109,13 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* Get time entries in organization
|
||||
*
|
||||
* If you only need time entries for a specific user, you can filter by `member_id`.
|
||||
* Without a member filter this returns the time entries of all members of the organization (for users who may view all time entries, such as owners and admins), not only your own.
|
||||
* To get only your own time entries, pass your member ID as `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID.
|
||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
|
||||
*
|
||||
* @return TimeEntryCollection<TimeEntryResource>
|
||||
* The `start` and `end` filters both apply to the start time of an entry, in UTC. Convert the user's local day boundaries to UTC first.
|
||||
* Results are paginated with `limit` (default 100, max 500) and `offset`; check `meta.total` and fetch further pages when needed.
|
||||
* To find the running timer, use `active=true` (or `GET /v1/users/me/time-entries/active`).
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
@@ -351,6 +354,11 @@ class TimeEntryController extends Controller
|
||||
* The parameters `group` and `sub_group` allow you to group the time entries by different criteria.
|
||||
* If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries.
|
||||
*
|
||||
* Durations are returned in `seconds` (divide by 3600 for hours) and amounts in `cost` as cents in the organization's currency (divide by 100 for money).
|
||||
* Filter by member with `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID.
|
||||
* Array filters use the query format `client_ids[]=<id>`.
|
||||
* Example: billable hours per client for a period: `group=client&billable=true&start=...&end=...`.
|
||||
*
|
||||
* @operationId getAggregatedTimeEntries
|
||||
*
|
||||
* @return array{
|
||||
@@ -584,6 +592,12 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* Create time entry
|
||||
*
|
||||
* `billable` is not taken from the project. To match the web app, set it to the project's `is_billable` value (or `false` without a project).
|
||||
*
|
||||
* A member can only have one running time entry (an entry with `end` set to `null`). Creating a running entry while another one runs fails with `time_entry_still_running`.
|
||||
* To start a new timer, first stop the running entry by updating its `end` to the current time, then create the new entry.
|
||||
* To log past work, send both `start` and `end` (UTC). Create one entry per block of work.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws TimeEntryStillRunningApiException
|
||||
*
|
||||
@@ -634,6 +648,8 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* Update time entry
|
||||
*
|
||||
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
|
||||
*
|
||||
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
|
||||
*
|
||||
* @operationId updateTimeEntry
|
||||
@@ -702,6 +718,10 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* Update multiple time entries
|
||||
*
|
||||
* Applies the same `changes` to every entry in `ids`. To find the IDs, list entries with `GET /organizations/{organization}/time-entries`
|
||||
* (filtered by `member_id`, the project and the other criteria), then send their IDs here.
|
||||
* When `changes.project_id` moves entries to another project, also set `changes.task_id` to a task of the new project or to `null`, because tasks belong to a project.
|
||||
*
|
||||
* @operationId updateMultipleTimeEntries
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
|
||||
@@ -12,7 +12,7 @@ class TimeZoneController extends Controller
|
||||
/**
|
||||
* Get all timezones
|
||||
*
|
||||
* @response object{key: string}[]
|
||||
* @response array{key: string}[]
|
||||
*
|
||||
* @operationId getTimezones
|
||||
*/
|
||||
|
||||
@@ -4,9 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Service\Dto\UserAgentDto;
|
||||
use App\Service\TimezoneService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -94,8 +92,6 @@ class UserProfileController extends Controller
|
||||
$this->validateTwoFactorAuthenticationState($request);
|
||||
|
||||
return Inertia::render('Profile/Show', [
|
||||
'timezones' => app(TimezoneService::class)->getSelectOptions(),
|
||||
'weekdays' => Weekday::toSelectArray(),
|
||||
'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
|
||||
'sessions' => $this->sessions($request),
|
||||
]);
|
||||
|
||||
@@ -42,6 +42,7 @@ class HandleInertiaRequests extends Middleware
|
||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
||||
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
||||
$hasGoals = Module::has('Goals') && Module::isEnabled('Goals');
|
||||
|
||||
/** @var BillingContract $billing */
|
||||
$billing = app(BillingContract::class);
|
||||
@@ -52,11 +53,13 @@ class HandleInertiaRequests extends Middleware
|
||||
'has_billing_extension' => $hasBilling,
|
||||
'has_invoicing_extension' => $hasInvoicing,
|
||||
'has_services_extension' => $hasServices,
|
||||
'has_goals_extension' => $hasGoals,
|
||||
'billing' => $currentOrganization !== null ? [
|
||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
||||
'is_blocked' => $billing->isBlocked($currentOrganization),
|
||||
'goal_limit' => $billing->getGoalLimit($currentOrganization),
|
||||
] : null,
|
||||
'flash' => [
|
||||
'message' => fn () => $request->session()->get('message'),
|
||||
|
||||
144
app/Http/Requests/V1/Goal/GoalFilterRules.php
Normal file
144
app/Http/Requests/V1/Goal/GoalFilterRules.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?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 Illuminate\Validation\Rules\ProhibitedIf;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
* Shared validation rules for the filters of a goal.
|
||||
*
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
trait GoalFilterRules
|
||||
{
|
||||
/**
|
||||
* @param \Closure(): bool $forEveryMember Whether the goal counts every member instead of one member, evaluated during validation
|
||||
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure|ProhibitedIf>>
|
||||
*/
|
||||
protected function filterRules(\Closure $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"
|
||||
Rule::prohibitedIf(fn (): bool => ! $forEveryMember()),
|
||||
],
|
||||
'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');
|
||||
}
|
||||
}
|
||||
168
app/Http/Requests/V1/Goal/GoalStoreRequest.php
Normal file
168
app/Http/Requests/V1/Goal/GoalStoreRequest.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?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 Illuminate\Validation\Rules\ProhibitedIf;
|
||||
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|ProhibitedIf|\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
|
||||
fn (): bool => $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'));
|
||||
}
|
||||
}
|
||||
130
app/Http/Requests/V1/Goal/GoalUpdateRequest.php
Normal file
130
app/Http/Requests/V1/Goal/GoalUpdateRequest.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?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;
|
||||
use Illuminate\Validation\Rules\ProhibitedIf;
|
||||
|
||||
/**
|
||||
* @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|ProhibitedIf|\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(fn (): bool => $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');
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class MemberUpdateRequest extends BaseFormRequest
|
||||
'string',
|
||||
Rule::enum(Role::class),
|
||||
],
|
||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
||||
'billable_rate' => array_merge(
|
||||
[
|
||||
'nullable',
|
||||
|
||||
@@ -36,6 +36,7 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
||||
'string',
|
||||
new CurrencyRule,
|
||||
],
|
||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
||||
'billable_rate' => array_merge(
|
||||
[
|
||||
'nullable',
|
||||
|
||||
@@ -55,6 +55,7 @@ class ProjectStoreRequest extends BaseFormRequest
|
||||
'required',
|
||||
'boolean',
|
||||
],
|
||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
||||
'billable_rate' => array_merge(
|
||||
[
|
||||
'nullable',
|
||||
|
||||
@@ -68,6 +68,7 @@ class ProjectUpdateRequest extends BaseFormRequest
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
],
|
||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
||||
'billable_rate' => array_merge([
|
||||
'nullable',
|
||||
],
|
||||
|
||||
@@ -31,6 +31,7 @@ class ProjectMemberStoreRequest extends BaseFormRequest
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
],
|
||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
||||
'billable_rate' => array_merge(
|
||||
[
|
||||
'nullable',
|
||||
|
||||
@@ -21,6 +21,7 @@ class ProjectMemberUpdateRequest extends BaseFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
||||
'billable_rate' => array_merge(
|
||||
[
|
||||
'nullable',
|
||||
|
||||
@@ -35,7 +35,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// Filter by member ID
|
||||
// Filter by member ID. Without it, users who may view all time entries (owners, admins) get the entries of every member; pass your own member ID (from GET /v1/users/me/memberships) to get only yours
|
||||
'member_id' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
||||
@@ -155,7 +155,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
'string',
|
||||
Rule::enum(TimeEntryType::class),
|
||||
],
|
||||
// Limit the number of returned time entries (default: 150)
|
||||
// Limit the number of returned time entries (default: 100)
|
||||
'limit' => [
|
||||
'integer',
|
||||
'min:1',
|
||||
|
||||
@@ -32,7 +32,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// ID of the organization member that the time entry should belong to
|
||||
// ID of the organization member that the time entry should belong to (a member ID from GET /v1/users/me/memberships or the members list, not a user ID)
|
||||
'member_id' => [
|
||||
'required',
|
||||
'string',
|
||||
@@ -86,7 +86,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
'after_or_equal:start',
|
||||
],
|
||||
// Whether time entry is billable
|
||||
// Whether time entry is billable. Not derived from the project: set it to the project's is_billable value to match the web app
|
||||
'billable' => [
|
||||
'required',
|
||||
'boolean',
|
||||
|
||||
@@ -43,9 +43,10 @@ class UserUpdateRequest extends BaseFormRequest
|
||||
'email' => [
|
||||
'email:rfc,strict',
|
||||
'max:255',
|
||||
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
||||
UniqueEloquent::make(User::class, 'email')->query(function (Builder $query) {
|
||||
/** @var Builder<User> $query */
|
||||
return $query->where('is_placeholder', '=', false);
|
||||
return $query->where('is_placeholder', '=', false)
|
||||
->whereKeyNot($this->user->getKey());
|
||||
}),
|
||||
],
|
||||
'photo' => [
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
interface PaginatedResourceCollection {}
|
||||
@@ -21,15 +21,15 @@ class ApiTokenResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of the API token, this ID is NOT a UUID */
|
||||
/** ID of the API token, this ID is NOT a UUID */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name of the API token */
|
||||
'name' => $this->resource->name,
|
||||
/** @var bool $revoked Whether the API token is revoked */
|
||||
/** Whether the API token is revoked */
|
||||
'revoked' => $this->resource->revoked,
|
||||
/** @var array<string> $scopes List of scopes that the API token has */
|
||||
/** List of scopes that the API token has */
|
||||
'scopes' => $this->resource->scopes,
|
||||
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
/** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
'expires_at' => $this->formatDateTime($this->resource->expires_at),
|
||||
|
||||
@@ -29,15 +29,15 @@ class ApiTokenWithAccessTokenResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of the API token, this ID is NOT a UUID */
|
||||
/** ID of the API token, this ID is NOT a UUID */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name of the API token */
|
||||
'name' => $this->resource->name,
|
||||
/** @var bool $revoked Whether the API token is revoked */
|
||||
/** Whether the API token is revoked */
|
||||
'revoked' => $this->resource->revoked,
|
||||
/** @var array<string> $scopes List of scopes that the API token has */
|
||||
/** List of scopes that the API token has */
|
||||
'scopes' => $this->resource->scopes,
|
||||
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
/** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
'expires_at' => $this->formatDateTime($this->resource->expires_at),
|
||||
|
||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Client;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ClientCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
class ClientCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,15 +21,15 @@ class ClientResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID */
|
||||
/** ID */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name */
|
||||
/** Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var bool $is_archived Whether the client is archived */
|
||||
/** Whether the client is archived */
|
||||
'is_archived' => $this->resource->is_archived,
|
||||
/** @var string $created_at When the tag was created */
|
||||
/** When the tag was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string $updated_at When the tag was last updated */
|
||||
/** When the tag was last updated */
|
||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
45
app/Http/Resources/V1/Goal/GoalCollection.php
Normal file
45
app/Http/Resources/V1/Goal/GoalCollection.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Goal;
|
||||
|
||||
use App\Models\Goal;
|
||||
use App\Service\Dto\GoalProgressDto;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class GoalCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* @var array<string, GoalProgressDto>
|
||||
*/
|
||||
private array $progressByGoalId;
|
||||
|
||||
/**
|
||||
* @param array<string, GoalProgressDto> $progressByGoalId
|
||||
*/
|
||||
public function __construct($resource, array $progressByGoalId)
|
||||
{
|
||||
parent::__construct($resource);
|
||||
$this->progressByGoalId = $progressByGoalId;
|
||||
}
|
||||
|
||||
protected function collects(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the resource collection into an array.
|
||||
*
|
||||
* @return array<array<string, string|bool|int|null|array<string, string|bool|int|null|array<int, string>>>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return $this->collection->map(function (Goal $goal) use ($request): array {
|
||||
return (new GoalResource($goal, $this->progressByGoalId[$goal->getKey()]))
|
||||
->toArray($request);
|
||||
})->all();
|
||||
}
|
||||
}
|
||||
90
app/Http/Resources/V1/Goal/GoalResource.php
Normal file
90
app/Http/Resources/V1/Goal/GoalResource.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Goal;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Goal;
|
||||
use App\Service\Dto\GoalProgressDto;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property Goal $resource
|
||||
*/
|
||||
class GoalResource extends BaseResource
|
||||
{
|
||||
private GoalProgressDto $progress;
|
||||
|
||||
public function __construct(Goal $resource, GoalProgressDto $progress)
|
||||
{
|
||||
parent::__construct($resource);
|
||||
|
||||
$this->progress = $progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null|array<string, string|bool|int|null|array<int, string>>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of the goal */
|
||||
'id' => $this->resource->id,
|
||||
/** Name */
|
||||
'name' => $this->resource->name,
|
||||
/** personal: a goal a member set for themselves, organization: a goal of the organization (team goals extension) */
|
||||
'type' => $this->resource->type->value,
|
||||
/** Whether the goal is reached with "at least" or "less than" the target time (at_least, less_than) */
|
||||
'comparison' => $this->resource->comparison->value,
|
||||
/** Target time in seconds */
|
||||
'target_seconds' => $this->resource->target_seconds,
|
||||
/** Recurring time frame (day, week, month) */
|
||||
'period' => $this->resource->period->value,
|
||||
/** ID of the member whose time entries count towards the goal, null if every member counts */
|
||||
'member_id' => $this->resource->member_id,
|
||||
/** Name of the member whose time entries count towards the goal, null if every member counts */
|
||||
'member_name' => $this->resource->member?->user->name,
|
||||
/** Timezone that defines the periods of the goal */
|
||||
'timezone' => $this->resource->timezone,
|
||||
/** Week start that defines weekly periods */
|
||||
'week_start' => $this->resource->week_start->value,
|
||||
/** Whether the goal is archived */
|
||||
'is_archived' => $this->resource->is_archived,
|
||||
'filters' => [
|
||||
/** @var array<string>|null $member_ids Filter by member IDs, member IDs are OR combined, only for goals that count every member */
|
||||
'member_ids' => $this->resource->filters->memberIds?->toArray(),
|
||||
/** @var array<string>|null $project_ids Filter by project IDs, project IDs are OR combined */
|
||||
'project_ids' => $this->resource->filters->projectIds?->toArray(),
|
||||
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
||||
'task_ids' => $this->resource->filters->taskIds?->toArray(),
|
||||
/** @var array<string>|null $tag_ids Filter by tag IDs, tag IDs are OR combined */
|
||||
'tag_ids' => $this->resource->filters->tagIds?->toArray(),
|
||||
/** Tag match type (contains, not_contains) */
|
||||
'tag_match_type' => $this->resource->filters->tagMatchType?->value,
|
||||
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
|
||||
'client_ids' => $this->resource->filters->clientIds?->toArray(),
|
||||
/** Filter by billable status */
|
||||
'billable' => $this->resource->filters->billable,
|
||||
/** Filter by time entry type (work, break) */
|
||||
'time_entry_type' => $this->resource->filters->timeEntryType?->value,
|
||||
],
|
||||
'progress' => [
|
||||
/** Start of the current period (inclusive) */
|
||||
'period_start' => $this->formatDateTime($this->progress->periodStart),
|
||||
/** End of the current period (exclusive) */
|
||||
'period_end' => $this->formatDateTime($this->progress->periodEnd),
|
||||
/** Seconds tracked in the current period that match the filters, incl. the running time entry */
|
||||
'tracked_seconds' => $this->progress->trackedSeconds,
|
||||
/** Status in the current period (in_progress, achieved, on_track, exceeded) */
|
||||
'status' => $this->progress->status->value,
|
||||
],
|
||||
/** Date when the goal was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** Date when the goal was last updated */
|
||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Invitation;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class InvitationCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
class InvitationCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,11 +21,11 @@ class InvitationResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of the invitation */
|
||||
/** ID of the invitation */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $email Email */
|
||||
/** Email */
|
||||
'email' => $this->resource->email,
|
||||
/** @var string $role Role */
|
||||
/** Role */
|
||||
'role' => $this->resource->role,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Member;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class MemberCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
class MemberCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -22,19 +22,19 @@ class MemberResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of membership */
|
||||
/** ID of membership */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $id ID of user */
|
||||
/** ID of user */
|
||||
'user_id' => $this->resource->user->id,
|
||||
/** @var string $name Name */
|
||||
/** Name */
|
||||
'name' => $this->resource->user->name,
|
||||
/** @var string $email Email */
|
||||
/** Email */
|
||||
'email' => $this->resource->user->email,
|
||||
/** @var string $role Role */
|
||||
/** Role */
|
||||
'role' => $this->resource->role,
|
||||
/** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
|
||||
/** Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
|
||||
'is_placeholder' => $this->resource->user->is_placeholder,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
/** Billable rate in cents per hour */
|
||||
'billable_rate' => $this->resource->billable_rate,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Member;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class PersonalMembershipCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
class PersonalMembershipCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,17 +21,17 @@ class PersonalMembershipResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of membership */
|
||||
/** ID of membership */
|
||||
'id' => $this->resource->id,
|
||||
'organization' => [
|
||||
/** @var string $id ID of organization */
|
||||
/** ID of organization */
|
||||
'id' => $this->resource->organization->id,
|
||||
/** @var string $name Name of organization */
|
||||
/** Name of organization */
|
||||
'name' => $this->resource->organization->name,
|
||||
/** @var string $currency Currency code (ISO 4217) of organization */
|
||||
/** Currency code (ISO 4217) of organization */
|
||||
'currency' => $this->resource->organization->currency,
|
||||
],
|
||||
/** @var string $role Role */
|
||||
/** Role */
|
||||
'role' => $this->resource->role,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -43,23 +43,23 @@ class OrganizationResource extends BaseResource
|
||||
$currencyService = app(CurrencyService::class);
|
||||
|
||||
return [
|
||||
/** @var string $id ID */
|
||||
/** ID */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name */
|
||||
/** Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var bool $color Personal organizations automatically created after registration */
|
||||
/** Personal organizations automatically created after registration */
|
||||
'is_personal' => $this->resource->personal_team,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
/** Billable rate in cents per hour */
|
||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
||||
/** Can members of the organization with role "employee" see the billable rates */
|
||||
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||
/** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
||||
/** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
||||
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
|
||||
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
|
||||
/** Prevent creating overlapping time entries (only new entries) */
|
||||
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
|
||||
/** @var bool $breaks_enabled Whether members of the organization can track breaks */
|
||||
/** Whether members of the organization can track breaks */
|
||||
'breaks_enabled' => $this->resource->breaks_enabled,
|
||||
/** @var string $currency Currency code (ISO 4217) */
|
||||
/** Currency code (ISO 4217) */
|
||||
'currency' => $this->resource->currency,
|
||||
/** @var string $currency_symbol Currency symbol */
|
||||
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),
|
||||
|
||||
@@ -4,12 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Project;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
class ProjectCollection extends ResourceCollection
|
||||
{
|
||||
private bool $showBillableRates;
|
||||
|
||||
|
||||
@@ -30,25 +30,25 @@ class ProjectResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of project */
|
||||
/** ID of project */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name of project */
|
||||
/** Name of project */
|
||||
'name' => $this->resource->name,
|
||||
/** @var string $color Color of project */
|
||||
/** Color of project */
|
||||
'color' => $this->resource->color,
|
||||
/** @var string|null $client_id ID of client */
|
||||
'client_id' => $this->resource->client_id,
|
||||
/** @var bool $is_archived Whether the client is archived */
|
||||
/** Whether the client is archived */
|
||||
'is_archived' => $this->resource->is_archived,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
/** Billable rate in cents per hour */
|
||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||
/** @var bool $is_billable Project time entries billable default */
|
||||
/** Project time entries billable default */
|
||||
'is_billable' => $this->resource->is_billable,
|
||||
/** @var int|null $estimated_time Estimated time in seconds */
|
||||
/** Estimated time in seconds */
|
||||
'estimated_time' => $this->resource->estimated_time,
|
||||
/** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
||||
/** Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
||||
'spent_time' => $this->resource->spent_time,
|
||||
/** @var bool $is_public Whether the project is public */
|
||||
/** Whether the project is public */
|
||||
'is_public' => $this->resource->is_public,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\ProjectMember;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ProjectMemberCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
class ProjectMemberCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,13 +21,13 @@ class ProjectMemberResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of project member */
|
||||
/** ID of project member */
|
||||
'id' => $this->resource->id,
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
/** Billable rate in cents per hour */
|
||||
'billable_rate' => $this->resource->billable_rate,
|
||||
/** @var string $member_id ID of the organization member */
|
||||
/** ID of the organization member */
|
||||
'member_id' => $this->resource->member_id,
|
||||
/** @var string $project_id ID of the project */
|
||||
/** ID of the project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -21,36 +21,36 @@ class DetailedReportResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of the report */
|
||||
/** ID of the report */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name */
|
||||
/** Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var string|null $email Description */
|
||||
/** Description */
|
||||
'description' => $this->resource->description,
|
||||
/** @var bool $is_public Whether the report can be accessed via an external link */
|
||||
/** Whether the report can be accessed via an external link */
|
||||
'is_public' => $this->resource->is_public,
|
||||
/** @var string|null $public_until Date until the report is public */
|
||||
'public_until' => $this->formatDateTime($this->resource->public_until),
|
||||
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
|
||||
'shareable_link' => $this->resource->getShareableLink(),
|
||||
'properties' => [
|
||||
/** @var string $group Type of first grouping */
|
||||
/** Type of first grouping */
|
||||
'group' => $this->resource->properties->group->value,
|
||||
/** @var string $sub_group Type of second grouping */
|
||||
/** Type of second grouping */
|
||||
'sub_group' => $this->resource->properties->subGroup->value,
|
||||
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
|
||||
/** Type of grouping of the historic aggregation (time chart) */
|
||||
'history_group' => $this->resource->properties->historyGroup->value,
|
||||
/** @var string $start Start date of the report */
|
||||
/** Start date of the report */
|
||||
'start' => $this->formatDateTime($this->resource->properties->start),
|
||||
/** @var string $end End date of the report */
|
||||
/** End date of the report */
|
||||
'end' => $this->formatDateTime($this->resource->properties->end),
|
||||
/** @var bool|null $active Whether the report is active */
|
||||
/** Whether the report is active */
|
||||
'active' => $this->resource->properties->active,
|
||||
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
|
||||
'member_ids' => $this->resource->properties->memberIds?->toArray(),
|
||||
/** @var bool|null $billable Filter by billable status */
|
||||
/** Filter by billable status */
|
||||
'billable' => $this->resource->properties->billable,
|
||||
/** @var string|null $time_entry_type Filter by time entry type */
|
||||
/** Filter by time entry type */
|
||||
'time_entry_type' => $this->resource->properties->timeEntryType?->value,
|
||||
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
|
||||
'client_ids' => $this->resource->properties->clientIds?->toArray(),
|
||||
@@ -58,18 +58,18 @@ class DetailedReportResource extends BaseResource
|
||||
'project_ids' => $this->resource->properties->projectIds?->toArray(),
|
||||
/** @var array<string>|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 */
|
||||
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
|
||||
/** @var array<string>|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 */
|
||||
/** Rounding type for time entries */
|
||||
'rounding_type' => $this->resource->properties->roundingType?->value,
|
||||
/** @var int|null $rounding_minutes Rounding minutes for time entries */
|
||||
/** Rounding minutes for time entries */
|
||||
'rounding_minutes' => $this->resource->properties->roundingMinutes,
|
||||
],
|
||||
/** @var string $created_at Date when the report was created */
|
||||
/** Date when the report was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string $updated_at Date when the report was last updated */
|
||||
/** Date when the report was last updated */
|
||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -73,13 +73,13 @@ class DetailedWithDataReportResource extends BaseResource
|
||||
$currencyService = app(CurrencyService::class);
|
||||
|
||||
return [
|
||||
/** @var string $name Name */
|
||||
/** Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var string|null $email Description */
|
||||
/** Description */
|
||||
'description' => $this->resource->description,
|
||||
/** @var string|null $public_until Date until the report is public */
|
||||
'public_until' => $this->formatDateTime($this->resource->public_until),
|
||||
/** @var string $currency Currency code (ISO 4217) */
|
||||
/** Currency code (ISO 4217) */
|
||||
'currency' => $this->resource->organization->currency,
|
||||
/** @var NumberFormat $number_format Number format */
|
||||
'number_format' => $this->resource->organization->number_format->value,
|
||||
@@ -94,15 +94,15 @@ class DetailedWithDataReportResource extends BaseResource
|
||||
/** @var TimeFormat $time_format Time format */
|
||||
'time_format' => $this->resource->organization->time_format->value,
|
||||
'properties' => [
|
||||
/** @var string $group Type of first grouping */
|
||||
/** Type of first grouping */
|
||||
'group' => $this->resource->properties->group->value,
|
||||
/** @var string $sub_group Type of second grouping */
|
||||
/** Type of second grouping */
|
||||
'sub_group' => $this->resource->properties->subGroup->value,
|
||||
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
|
||||
/** Type of grouping of the historic aggregation (time chart) */
|
||||
'history_group' => $this->resource->properties->historyGroup->value,
|
||||
/** @var string $start Start date of the report */
|
||||
/** Start date of the report */
|
||||
'start' => $this->formatDateTime($this->resource->properties->start),
|
||||
/** @var string $end End date of the report */
|
||||
/** End date of the report */
|
||||
'end' => $this->formatDateTime($this->resource->properties->end),
|
||||
],
|
||||
/** @var array{
|
||||
|
||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Report;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ReportCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
class ReportCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,21 +21,21 @@ class ReportResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of the report */
|
||||
/** ID of the report */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name */
|
||||
/** Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var string|null $email Description */
|
||||
/** Description */
|
||||
'description' => $this->resource->description,
|
||||
/** @var bool $is_public Whether the report can be accessed via an external link */
|
||||
/** Whether the report can be accessed via an external link */
|
||||
'is_public' => $this->resource->is_public,
|
||||
/** @var string|null $public_until Date until the report is public */
|
||||
'public_until' => $this->formatDateTime($this->resource->public_until),
|
||||
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
|
||||
'shareable_link' => $this->resource->getShareableLink(),
|
||||
/** @var string $created_at Date when the report was created */
|
||||
/** Date when the report was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string $updated_at Date when the report was last updated */
|
||||
/** Date when the report was last updated */
|
||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Tag;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class TagCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
class TagCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,13 +21,13 @@ class TagResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID */
|
||||
/** ID */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name */
|
||||
/** Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var string $created_at When the tag was created */
|
||||
/** When the tag was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string $updated_at When the tag was last updated */
|
||||
/** When the tag was last updated */
|
||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Task;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class TaskCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
class TaskCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -22,21 +22,21 @@ class TaskResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID */
|
||||
/** ID */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name */
|
||||
/** Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var bool $is_done Whether the task is done */
|
||||
/** Whether the task is done */
|
||||
'is_done' => $this->resource->is_done,
|
||||
/** @var string $project_id ID of the project */
|
||||
/** ID of the project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
/** @var int|null $estimated_time Estimated time in seconds */
|
||||
/** Estimated time in seconds */
|
||||
'estimated_time' => $this->resource->estimated_time,
|
||||
/** @var int $spent_time Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
||||
/** Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
||||
'spent_time' => $this->resource->spent_time,
|
||||
/** @var string $created_at When the tag was created */
|
||||
/** When the tag was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** @var string $updated_at When the tag was last updated */
|
||||
/** When the tag was last updated */
|
||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\TimeEntry;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class TimeEntryCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
class TimeEntryCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,10 +21,10 @@ class TimeEntryResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of time entry */
|
||||
/** ID of time entry */
|
||||
'id' => $this->resource->id,
|
||||
/**
|
||||
* @var string $start Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)
|
||||
* Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)
|
||||
*/
|
||||
'start' => $this->formatDateTime($this->resource->start),
|
||||
/**
|
||||
@@ -35,19 +35,19 @@ class TimeEntryResource extends BaseResource
|
||||
'duration' => (int) $this->resource->getDuration()?->totalSeconds,
|
||||
/** @var string|null $description Description of time entry */
|
||||
'description' => $this->resource->description,
|
||||
/** @var string|null $task_id ID of task */
|
||||
/** ID of task */
|
||||
'task_id' => $this->resource->task_id,
|
||||
/** @var string|null $project_id ID of project */
|
||||
/** ID of project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
/** @var string $organization_id ID of organization */
|
||||
/** ID of organization */
|
||||
'organization_id' => $this->resource->organization_id,
|
||||
/** @var string $user_id ID of user */
|
||||
/** ID of user */
|
||||
'user_id' => $this->resource->user_id,
|
||||
/** @var array<string> $tags List of tag IDs */
|
||||
/** List of tag IDs */
|
||||
'tags' => $this->resource->tags ?? [],
|
||||
/** @var bool $billable Whether time entry is billable */
|
||||
/** Whether time entry is billable */
|
||||
'billable' => $this->resource->billable,
|
||||
/** @var string $type Type of the time entry (`work` time or a `break`) */
|
||||
/** Type of the time entry (`work` time or a `break`) */
|
||||
'type' => $this->resource->type->value,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -22,21 +22,21 @@ class UserResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of user */
|
||||
/** ID of user */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name of user */
|
||||
/** Name of user */
|
||||
'name' => $this->resource->name,
|
||||
/** @var string $email Email of user */
|
||||
/** Email of user */
|
||||
'email' => $this->resource->email,
|
||||
/** @var string|null $pending_email Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */
|
||||
/** Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */
|
||||
'pending_email' => $this->resource->pending_email,
|
||||
/** @var string $profile_photo_url Profile photo URL */
|
||||
/** Profile photo URL */
|
||||
'profile_photo_url' => $this->resource->profile_photo_url,
|
||||
/** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */
|
||||
/** Timezone (f.e. Europe/Berlin or America/New_York) */
|
||||
'timezone' => $this->resource->timezone,
|
||||
/** @var Weekday $week_start Starting day of the week */
|
||||
'week_start' => $this->resource->week_start->value,
|
||||
/** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */
|
||||
/** Whether to email the user when a time entry has been running for more than 8 hours */
|
||||
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,15 +4,19 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AuditableWithoutOwner;
|
||||
use Database\Factories\AuditFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Carbon;
|
||||
use OwenIt\Auditing\Models\Audit as PackageAuditModel;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string|null $user_type
|
||||
* @property string|null $user_id
|
||||
* @property string|null $actor_type
|
||||
* @property string|null $actor_id
|
||||
* @property string $event
|
||||
* @property string $auditable_type
|
||||
* @property string $auditable_id
|
||||
@@ -22,13 +26,71 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
|
||||
* @property string|null $ip_address
|
||||
* @property string|null $user_agent
|
||||
* @property string|null $tags
|
||||
* @property string|null $owner_user_id
|
||||
* @property string|null $owner_organization_id
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read User|null $ownerUser
|
||||
* @property-read Organization|null $ownerOrganization
|
||||
*
|
||||
* @method static AuditFactory factory()
|
||||
* @method static Builder<Audit> whereMissingOwner()
|
||||
*/
|
||||
class Audit extends PackageAuditModel
|
||||
{
|
||||
/** @use HasFactory<AuditFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function ownerUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'owner_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, $this>
|
||||
*/
|
||||
public function ownerOrganization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'owner_organization_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether audits of the given auditable type intentionally have no owner (see AuditableWithoutOwner).
|
||||
*/
|
||||
public static function isAuditableTypeWithoutOwner(string $auditableType): bool
|
||||
{
|
||||
$modelClass = Relation::getMorphedModel($auditableType) ?? $auditableType;
|
||||
|
||||
return is_subclass_of($modelClass, AuditableWithoutOwner::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auditable types (morph aliases) whose audits intentionally have no owner (see AuditableWithoutOwner).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function getAuditableTypesWithoutOwner(): array
|
||||
{
|
||||
return collect(Relation::morphMap())
|
||||
->filter(fn (string $modelClass): bool => is_subclass_of($modelClass, AuditableWithoutOwner::class))
|
||||
->keys()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Audits that have neither an owner organization nor an owner user, although their auditable type should have one.
|
||||
* These are audits whose owner no longer exists or could not be determined (yet).
|
||||
*
|
||||
* @param Builder<Audit> $builder
|
||||
*/
|
||||
public function scopeWhereMissingOwner(Builder $builder): void
|
||||
{
|
||||
$builder->whereNull('owner_organization_id')
|
||||
->whereNull('owner_user_id')
|
||||
->whereNotIn('auditable_type', self::getAuditableTypesWithoutOwner());
|
||||
}
|
||||
}
|
||||
|
||||
22
app/Models/Concerns/AuditableThroughParent.php
Normal file
22
app/Models/Concerns/AuditableThroughParent.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Marks an auditable model whose audits are owned by the owner of its parent model,
|
||||
* because the model itself has no organization_id (for example a project member belongs to an organization via its project).
|
||||
* The parent model has to have an organization_id column.
|
||||
* This is used to set the owner of new audits (CustomAuditable) and to backfill the owner of existing audits.
|
||||
*/
|
||||
interface AuditableThroughParent
|
||||
{
|
||||
/**
|
||||
* @return BelongsTo<covariant Model, covariant Model>
|
||||
*/
|
||||
public function getAuditParentRelation(): BelongsTo;
|
||||
}
|
||||
14
app/Models/Concerns/AuditableWithoutOwner.php
Normal file
14
app/Models/Concerns/AuditableWithoutOwner.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
/**
|
||||
* Marks an auditable model whose audits intentionally have no owner (neither an organization nor a user),
|
||||
* either because the model belongs to neither of them, or because the model and its audits have to be kept
|
||||
* when its organization or user is deleted (for example billing records).
|
||||
* Audits of these models are not deleted together with an organization or user,
|
||||
* and are not considered as missing an owner (for example by the audit backfill command).
|
||||
*/
|
||||
interface AuditableWithoutOwner {}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use OwenIt\Auditing\Auditable;
|
||||
|
||||
trait CustomAuditable
|
||||
@@ -19,4 +21,92 @@ trait CustomAuditable
|
||||
{
|
||||
$this->auditEvents = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The organization that owns the audited model.
|
||||
* The audits of the model are deleted (via foreign key cascade) when the organization is deleted.
|
||||
*/
|
||||
public function getAuditOwnerOrganizationId(): ?string
|
||||
{
|
||||
if ($this instanceof AuditableThroughParent) {
|
||||
$relation = $this->getAuditParentRelation();
|
||||
$parentId = $this->getAttributes()[$relation->getForeignKeyName()] ?? null;
|
||||
if ($parentId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Note: The loaded parent is only used if it still matches the foreign key and was loaded with the organization
|
||||
$relationName = $relation->getRelationName();
|
||||
if ($this->relationLoaded($relationName)) {
|
||||
$parent = $this->getRelation($relationName);
|
||||
if ($parent instanceof Model
|
||||
&& $parent->getAttribute($relation->getOwnerKeyName()) === $parentId
|
||||
&& array_key_exists('organization_id', $parent->getAttributes())) {
|
||||
/** @var string|null $organizationId */
|
||||
$organizationId = $parent->getAttributes()['organization_id'];
|
||||
|
||||
return $organizationId;
|
||||
}
|
||||
}
|
||||
|
||||
/** @var string|null $organizationId */
|
||||
$organizationId = $relation->getRelated()->newQuery()
|
||||
->toBase()
|
||||
->where($relation->getOwnerKeyName(), $parentId)
|
||||
->value('organization_id');
|
||||
|
||||
return $organizationId;
|
||||
}
|
||||
|
||||
return $this->getAttributes()['organization_id'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user that owns the audited model.
|
||||
* The audits of the model are deleted (via foreign key cascade) when the user is deleted.
|
||||
*/
|
||||
public function getAuditOwnerUserId(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Models that are the owner of their own audits can not record the deletion audit,
|
||||
* since the audit would reference the already deleted model and therefore violate the foreign key.
|
||||
*/
|
||||
protected function isAuditOwnerOfItself(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int|string, string>
|
||||
*/
|
||||
public function getAuditEvents(): array
|
||||
{
|
||||
$events = $this->auditEvents ?? Config::get('audit.events', [
|
||||
'created',
|
||||
'updated',
|
||||
'deleted',
|
||||
'restored',
|
||||
]);
|
||||
|
||||
if ($this->isAuditOwnerOfItself()) {
|
||||
$events = array_filter($events, fn (string $value, int|string $key): bool => (is_int($key) ? $value : $key) !== 'deleted', ARRAY_FILTER_USE_BOTH);
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function transformAudit(array $data): array
|
||||
{
|
||||
$data['owner_organization_id'] = $this->getAuditOwnerOrganizationId();
|
||||
$data['owner_user_id'] = $this->getAuditOwnerUserId();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
114
app/Models/Goal.php
Normal file
114
app/Models/Goal.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\GoalComparison;
|
||||
use App\Enums\GoalPeriod;
|
||||
use App\Enums\GoalType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use App\Service\Dto\GoalFiltersDto;
|
||||
use Database\Factories\GoalFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $name
|
||||
* @property GoalType $type
|
||||
* @property GoalComparison $comparison
|
||||
* @property int $target_seconds
|
||||
* @property GoalPeriod $period
|
||||
* @property GoalFiltersDto $filters
|
||||
* @property string $timezone
|
||||
* @property Weekday $week_start
|
||||
* @property Carbon|null $archived_at
|
||||
* @property-read bool $is_archived
|
||||
* @property string $organization_id
|
||||
* @property string|null $member_id
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Organization $organization
|
||||
* @property-read Member|null $member
|
||||
*
|
||||
* @method static GoalFactory factory()
|
||||
*/
|
||||
class Goal extends Model
|
||||
{
|
||||
/** @use HasFactory<GoalFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'type' => GoalType::class,
|
||||
'comparison' => GoalComparison::class,
|
||||
'target_seconds' => 'int',
|
||||
'period' => GoalPeriod::class,
|
||||
'filters' => GoalFiltersDto::class,
|
||||
'week_start' => Weekday::class,
|
||||
'archived_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, $this>
|
||||
*/
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The member whose time entries count towards the goal, null for goals that count every member.
|
||||
*
|
||||
* @return BelongsTo<Member, $this>
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class, 'member_id');
|
||||
}
|
||||
|
||||
public function isPersonalGoalOf(Member $member): bool
|
||||
{
|
||||
return $this->type === GoalType::Personal && $this->member_id === $member->getKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Attribute<bool, never>
|
||||
*/
|
||||
protected function isArchived(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value, array $attributes) => isset($attributes['archived_at']),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Goal> $builder
|
||||
* @return Builder<Goal>
|
||||
*/
|
||||
public function scopeArchived(Builder $builder): Builder
|
||||
{
|
||||
return $builder->whereNotNull('archived_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Goal> $builder
|
||||
* @return Builder<Goal>
|
||||
*/
|
||||
public function scopeNotArchived(Builder $builder): Builder
|
||||
{
|
||||
return $builder->whereNull('archived_at');
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property-read User $user
|
||||
* @property-read Collection<int, ProjectMember> $projectMembers
|
||||
* @property-read Collection<int, TimeEntry> $timeEntries
|
||||
* @property-read Collection<int, Goal> $goals
|
||||
*
|
||||
* @method static MemberFactory factory()
|
||||
*/
|
||||
@@ -78,4 +79,14 @@ class Member extends Pivot implements AuditableContract
|
||||
{
|
||||
return $this->hasMany(ProjectMember::class, 'member_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Goals whose progress counts the time entries of this member.
|
||||
*
|
||||
* @return HasMany<Goal, $this>
|
||||
*/
|
||||
public function goals(): HasMany
|
||||
{
|
||||
return $this->hasMany(Goal::class, 'member_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,16 @@ class Organization extends Model implements AuditableContract
|
||||
protected $attributes = [
|
||||
];
|
||||
|
||||
public function getAuditOwnerOrganizationId(): ?string
|
||||
{
|
||||
return $this->getKey();
|
||||
}
|
||||
|
||||
protected function isAuditOwnerOfItself(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the users that belong to the team.
|
||||
*
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AuditableThroughParent;
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\ProjectMemberFactory;
|
||||
@@ -29,7 +30,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
|
||||
* @method static ProjectMemberFactory factory()
|
||||
*/
|
||||
class ProjectMember extends Model implements AuditableContract
|
||||
class ProjectMember extends Model implements AuditableContract, AuditableThroughParent
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
@@ -82,4 +83,12 @@ class ProjectMember extends Model implements AuditableContract
|
||||
$query->whereBelongsTo($organization, 'organization');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Project, $this>
|
||||
*/
|
||||
public function getAuditParentRelation(): BelongsTo
|
||||
{
|
||||
return $this->project();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,16 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
'send_time_entry_still_running_email' => true,
|
||||
];
|
||||
|
||||
public function getAuditOwnerUserId(): ?string
|
||||
{
|
||||
return $this->getKey();
|
||||
}
|
||||
|
||||
protected function isAuditOwnerOfItself(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL to the user's profile photo.
|
||||
*
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\BillingContract;
|
||||
use App\Service\GoalsContract;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\IpLookup\NoIpLookupService;
|
||||
use App\Service\PermissionStore;
|
||||
@@ -103,6 +104,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
// Extensions
|
||||
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
|
||||
$this->app->bind(BillingContract::class);
|
||||
$this->app->bind(GoalsContract::class);
|
||||
|
||||
// Storage
|
||||
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
169
app/Service/Dto/GoalFiltersDto.php
Normal file
169
app/Service/Dto/GoalFiltersDto.php
Normal 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;
|
||||
}
|
||||
}
|
||||
22
app/Service/Dto/GoalProgressDto.php
Normal file
22
app/Service/Dto/GoalProgressDto.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Dto;
|
||||
|
||||
use App\Enums\GoalStatus;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
readonly class GoalProgressDto
|
||||
{
|
||||
/**
|
||||
* @param Carbon $periodStart Start of the period in UTC (inclusive)
|
||||
* @param Carbon $periodEnd End of the period in UTC (exclusive)
|
||||
*/
|
||||
public function __construct(
|
||||
public Carbon $periodStart,
|
||||
public Carbon $periodEnd,
|
||||
public int $trackedSeconds,
|
||||
public GoalStatus $status,
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
122
app/Service/GoalProgressService.php
Normal file
122
app/Service/GoalProgressService.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?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 App\Service\Dto\GoalProgressDto;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class GoalProgressService
|
||||
{
|
||||
/**
|
||||
* Progress of the goal in the period that contains $now.
|
||||
*/
|
||||
public function getCurrentProgress(Goal $goal, Carbon $now): GoalProgressDto
|
||||
{
|
||||
[$periodStart, $periodEnd] = $this->getPeriodBounds($goal, $now);
|
||||
$trackedSeconds = $this->getProgress($goal, $periodStart, $periodEnd);
|
||||
|
||||
return new GoalProgressDto($periodStart, $periodEnd, $trackedSeconds, $this->getStatus($goal, $trackedSeconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress of the goals in the period that contains $now, keyed by goal ID.
|
||||
* Runs one query per goal, since every goal has its own filters, timezone and period.
|
||||
*
|
||||
* @param Collection<int, Goal> $goals
|
||||
* @return array<string, GoalProgressDto>
|
||||
*/
|
||||
public function getCurrentProgressForGoals(Collection $goals, Carbon $now): array
|
||||
{
|
||||
$progress = [];
|
||||
foreach ($goals as $goal) {
|
||||
$progress[$goal->getKey()] = $this->getCurrentProgress($goal, $now);
|
||||
}
|
||||
|
||||
return $progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
private 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
64
app/Service/GoalsContract.php
Normal file
64
app/Service/GoalsContract.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -32,7 +32,7 @@ return [
|
||||
*/
|
||||
|
||||
'user' => [
|
||||
'morph_prefix' => 'user',
|
||||
'morph_prefix' => 'actor',
|
||||
'guards' => [
|
||||
'web',
|
||||
'api',
|
||||
|
||||
@@ -35,7 +35,7 @@ return [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
@@ -47,7 +47,7 @@ return [
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'forge'),
|
||||
@@ -57,12 +57,12 @@ return [
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
'sslmode' => env('DB_SSL_MODE', 'prefer'),
|
||||
],
|
||||
|
||||
'pgsql_test' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'host' => env('DB_TEST_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_TEST_PORT', '5432'),
|
||||
'database' => env('DB_TEST_DATABASE', 'forge'),
|
||||
@@ -72,12 +72,12 @@ return [
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
'sslmode' => env('DB_SSL_MODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Extensions\Scramble\ApiExceptionTypeToSchema;
|
||||
use App\Extensions\Scramble\PaginatedResourceCollectionTypeToSchema;
|
||||
use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess;
|
||||
|
||||
return [
|
||||
@@ -28,7 +27,30 @@ return [
|
||||
/*
|
||||
* Description rendered on the home page of the API documentation (`/docs/api`).
|
||||
*/
|
||||
'description' => '',
|
||||
'description' => <<<'MD'
|
||||
## Getting started
|
||||
|
||||
All organization endpoints live under `/v1/organizations/{organization}`, where `{organization}` is the organization's ID. Authenticate with `Authorization: Bearer <token>` and send `Accept: application/json`.
|
||||
|
||||
**1. Find yourself.** Call `GET /v1/users/me/memberships`. Each membership contains the **organization ID** (use it as `{organization}` in paths) and your **member ID** in that organization (the membership `id`). Most endpoints filter by member ID, not by user ID.
|
||||
|
||||
**2. Scope to your own data.** For owners and admins, `GET /time-entries` and `GET /time-entries/aggregate` return the whole organization's time entries unless you pass `member_id`. When acting for "me", always pass your member ID, both when reading and before changing entries.
|
||||
|
||||
**3. Resolve names to IDs.** Look up projects, clients, tags, tasks and members by name with their list endpoints (`GET /projects`, `GET /clients`, `GET /tags`, `GET /tasks`, `GET /members`). Never guess IDs.
|
||||
|
||||
**4. Use UTC.** All timestamps are sent and returned in UTC as `Y-m-d\TH:i:s\Z` (example: `2026-10-02T07:30:00Z`). Convert the user's local times and day boundaries to UTC before sending them.
|
||||
|
||||
**5. Money is in cents.** Billable rates and costs are integers in cents of the organization's currency (`8000` means 80.00).
|
||||
|
||||
## Common tasks
|
||||
|
||||
- **Start a timer:** stop the running entry first (find it with `GET /v1/users/me/time-entries/active`, then `PUT` its `end`), then `POST /time-entries` with `start` and `end: null`. Only one entry can run per member.
|
||||
- **Log past work:** `POST /time-entries` once per block with `member_id`, `start`, `end`, `project_id` and `billable` set to the project's `is_billable` (it is not derived automatically).
|
||||
- **Fix or stop an entry:** `PUT /time-entries/{timeEntry}` with the new `start` or `end` in UTC.
|
||||
- **Move entries to another project:** list them with `member_id` and filters, then `PATCH /time-entries` with their `ids` and `changes.project_id`, plus `changes.task_id` set to a task of the new project or `null`.
|
||||
- **Totals and reports:** `GET /time-entries/aggregate` with `group` (for example `client` or `project`) and `start`/`end`; durations are in `seconds`, amounts in `cost` (cents).
|
||||
- **Share a report:** `POST /reports` with `is_public: true` and use the returned `shareable_link`.
|
||||
MD,
|
||||
],
|
||||
|
||||
/*
|
||||
@@ -78,6 +100,5 @@ return [
|
||||
|
||||
'extensions' => [
|
||||
ApiExceptionTypeToSchema::class,
|
||||
PaginatedResourceCollectionTypeToSchema::class,
|
||||
],
|
||||
];
|
||||
|
||||
161
database/factories/GoalFactory.php
Normal file
161
database/factories/GoalFactory.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\GoalComparison;
|
||||
use App\Enums\GoalPeriod;
|
||||
use App\Enums\GoalType;
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Goal;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Service\Dto\GoalFiltersDto;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Goal>
|
||||
*/
|
||||
class GoalFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$filters = new GoalFiltersDto;
|
||||
$filters->timeEntryType = TimeEntryType::Work;
|
||||
|
||||
return [
|
||||
'name' => $this->faker->sentence(3),
|
||||
'type' => GoalType::Personal,
|
||||
'comparison' => $this->faker->randomElement(GoalComparison::cases()),
|
||||
'target_seconds' => $this->faker->numberBetween(1, 40) * 3600,
|
||||
'period' => $this->faker->randomElement(GoalPeriod::cases()),
|
||||
'filters' => $filters,
|
||||
'timezone' => 'UTC',
|
||||
'week_start' => Weekday::Monday,
|
||||
'archived_at' => null,
|
||||
'organization_id' => Organization::factory(),
|
||||
// The member has to belong to the organization of the goal
|
||||
'member_id' => fn (array $attributes) => Member::factory()->state([
|
||||
'organization_id' => $attributes['organization_id'],
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Goal in the given organization, for a new member of that organization unless forMember() is used.
|
||||
*/
|
||||
public function forOrganization(Organization $organization): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'organization_id' => $organization->getKey(),
|
||||
'member_id' => Member::factory()->forOrganization($organization),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Personal goal of the given member.
|
||||
*/
|
||||
public function forMember(Member $member): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'type' => GoalType::Personal,
|
||||
'member_id' => $member->getKey(),
|
||||
'organization_id' => $member->organization_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization goal that counts the time of the given member.
|
||||
*/
|
||||
public function organizationGoalForMember(Member $member): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'type' => GoalType::Organization,
|
||||
'member_id' => $member->getKey(),
|
||||
'organization_id' => $member->organization_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization goal that counts the time of every member.
|
||||
*/
|
||||
public function forEveryMember(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'type' => GoalType::Organization,
|
||||
'member_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function type(GoalType $type): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'type' => $type,
|
||||
]);
|
||||
}
|
||||
|
||||
public function archived(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'archived_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function timezone(string $timezone): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'timezone' => $timezone,
|
||||
]);
|
||||
}
|
||||
|
||||
public function weekStart(Weekday $weekStart): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'week_start' => $weekStart,
|
||||
]);
|
||||
}
|
||||
|
||||
public function atLeast(int $targetSeconds): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'comparison' => GoalComparison::AtLeast,
|
||||
'target_seconds' => $targetSeconds,
|
||||
]);
|
||||
}
|
||||
|
||||
public function lessThan(int $targetSeconds): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'comparison' => GoalComparison::LessThan,
|
||||
'target_seconds' => $targetSeconds,
|
||||
]);
|
||||
}
|
||||
|
||||
public function period(GoalPeriod $period): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'period' => $period,
|
||||
]);
|
||||
}
|
||||
|
||||
public function filters(GoalFiltersDto $filters): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
|
||||
public function randomCreatedAt(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ class UserFactory extends Factory
|
||||
|
||||
public function withProfilePicture(): static
|
||||
{
|
||||
$profilePhoto = $this->faker->image(null, 500, 500);
|
||||
$profilePhoto = $this->generateProfilePhoto();
|
||||
/** @see FileHelpers::hashName */
|
||||
$path = 'profile-photos/'.Str::random(40).'.png';
|
||||
Storage::disk(config('filesystems.public'))->put($path, $profilePhoto);
|
||||
@@ -103,6 +103,21 @@ class UserFactory extends Factory
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a PNG image with a random background color.
|
||||
* Note: The image is generated locally, since downloading a placeholder image (Faker image()) depends on an external service.
|
||||
*/
|
||||
private function generateProfilePhoto(): string
|
||||
{
|
||||
$image = imagecreatetruecolor(500, 500);
|
||||
$color = imagecolorallocate($image, $this->faker->numberBetween(0, 255), $this->faker->numberBetween(0, 255), $this->faker->numberBetween(0, 255));
|
||||
imagefill($image, 0, 0, $color);
|
||||
ob_start();
|
||||
imagepng($image);
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the user should have a personal team.
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,8 @@ class CreateAuditsTable extends Migration
|
||||
|
||||
Schema::connection($connection)->create($table, function (Blueprint $table): void {
|
||||
|
||||
$morphPrefix = config('audit.user.morph_prefix', 'user');
|
||||
// Note: The morph prefix is hardcoded, since the columns are renamed in a later migration
|
||||
$morphPrefix = 'user';
|
||||
|
||||
$table->bigIncrements('id');
|
||||
$table->string($morphPrefix.'_type')->nullable();
|
||||
|
||||
56
database/migrations/2026_09_02_000001_create_goals_table.php
Normal file
56
database/migrations/2026_09_02_000001_create_goals_table.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('goals', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name');
|
||||
// personal: a goal a member set for themselves, organization: a goal the organization set (team goals extension)
|
||||
$table->string('type');
|
||||
$table->string('comparison');
|
||||
$table->unsignedBigInteger('target_seconds');
|
||||
$table->string('period');
|
||||
$table->jsonb('filters');
|
||||
// Timezone and week start define the periods of the goal, they are pinned on the goal like on a report
|
||||
$table->string('timezone');
|
||||
$table->string('week_start');
|
||||
$table->dateTime('archived_at')->nullable();
|
||||
$table->uuid('organization_id');
|
||||
$table->foreign('organization_id')
|
||||
->references('id')
|
||||
->on('organizations')
|
||||
->restrictOnDelete()
|
||||
->cascadeOnUpdate();
|
||||
// Member whose time entries count towards the goal, null means every member of the organization
|
||||
// (organization goals only). Personal goals always have a member.
|
||||
$table->uuid('member_id')->nullable();
|
||||
$table->foreign('member_id')
|
||||
->references('id')
|
||||
->on('members')
|
||||
->restrictOnDelete()
|
||||
->cascadeOnUpdate();
|
||||
$table->timestamps();
|
||||
$table->index(['organization_id', 'member_id']);
|
||||
$table->index(['organization_id', 'type']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('goals');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Note: Renaming columns and adding nullable columns without default are metadata-only operations in PostgreSQL,
|
||||
* so this migration is fast even for a large audits table.
|
||||
* The indexes and foreign keys for the new columns are added in a separate non-transactional migration.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('audits', function (Blueprint $table): void {
|
||||
$table->renameColumn('user_type', 'actor_type');
|
||||
$table->renameColumn('user_id', 'actor_id');
|
||||
$table->renameIndex('audits_user_id_user_type_index', 'audits_actor_id_actor_type_index');
|
||||
$table->uuid('owner_user_id')->nullable();
|
||||
$table->uuid('owner_organization_id')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('audits', function (Blueprint $table): void {
|
||||
$table->dropColumn('owner_user_id');
|
||||
$table->dropColumn('owner_organization_id');
|
||||
$table->renameIndex('audits_actor_id_actor_type_index', 'audits_user_id_user_type_index');
|
||||
$table->renameColumn('actor_type', 'user_type');
|
||||
$table->renameColumn('actor_id', 'user_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* PostgreSQL cannot build an index concurrently inside a transaction.
|
||||
* Keeping this migration non-transactional prevents long write locks on the (large) audits table in production.
|
||||
* Every step is idempotent, so the migration can be re-run if it fails halfway.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $withinTransaction = false;
|
||||
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$this->createIndex('audits_owner_user_id_index', 'owner_user_id');
|
||||
$this->createIndex('audits_owner_organization_id_index', 'owner_organization_id');
|
||||
|
||||
$this->createForeignKey('audits_owner_user_id_foreign', 'owner_user_id', 'users');
|
||||
$this->createForeignKey('audits_owner_organization_id_foreign', 'owner_organization_id', 'organizations');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE audits DROP CONSTRAINT IF EXISTS audits_owner_user_id_foreign');
|
||||
DB::statement('ALTER TABLE audits DROP CONSTRAINT IF EXISTS audits_owner_organization_id_foreign');
|
||||
DB::statement('DROP INDEX'.$this->concurrently().' IF EXISTS audits_owner_user_id_index');
|
||||
DB::statement('DROP INDEX'.$this->concurrently().' IF EXISTS audits_owner_organization_id_index');
|
||||
}
|
||||
|
||||
private function createIndex(string $index, string $column): void
|
||||
{
|
||||
$state = DB::selectOne(
|
||||
<<<'SQL'
|
||||
SELECT pg_index.indisvalid::int AS valid
|
||||
FROM pg_index
|
||||
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
|
||||
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
|
||||
WHERE pg_namespace.nspname = current_schema()
|
||||
AND pg_class.relname = ?
|
||||
SQL,
|
||||
[$index],
|
||||
);
|
||||
|
||||
if ($state !== null && (bool) $state->valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A failed concurrent index build leaves an invalid index behind, that needs to be dropped before rebuilding
|
||||
if ($state !== null) {
|
||||
DB::statement('DROP INDEX'.$this->concurrently().' '.$index);
|
||||
}
|
||||
|
||||
DB::statement('CREATE INDEX'.$this->concurrently().' '.$index.' ON audits ('.$column.')');
|
||||
}
|
||||
|
||||
private function createForeignKey(string $constraint, string $column, string $referencedTable): void
|
||||
{
|
||||
$exists = DB::selectOne(
|
||||
<<<'SQL'
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
JOIN pg_namespace ON pg_namespace.oid = pg_constraint.connamespace
|
||||
WHERE pg_namespace.nspname = current_schema()
|
||||
AND pg_constraint.conname = ?
|
||||
SQL,
|
||||
[$constraint],
|
||||
) !== null;
|
||||
|
||||
// Note: Adding the constraint as NOT VALID only needs a short lock, the validation of the existing rows
|
||||
// afterward does not block reads or writes on the audits table.
|
||||
if (! $exists) {
|
||||
DB::statement('ALTER TABLE audits ADD CONSTRAINT '.$constraint.' FOREIGN KEY ('.$column.') REFERENCES '.$referencedTable.' (id) ON DELETE CASCADE NOT VALID');
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE audits VALIDATE CONSTRAINT '.$constraint);
|
||||
}
|
||||
|
||||
private function concurrently(): string
|
||||
{
|
||||
return DB::transactionLevel() === 0 ? ' CONCURRENTLY' : '';
|
||||
}
|
||||
};
|
||||
@@ -4,11 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Enums\GoalPeriod;
|
||||
use App\Enums\Role;
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Events\DatabaseSeederAfterSeed;
|
||||
use App\Events\DatabaseSeederBeforeDelete;
|
||||
use App\Models\Audit;
|
||||
use App\Models\Client;
|
||||
use App\Models\Goal;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
@@ -19,6 +22,7 @@ use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\Dto\GoalFiltersDto;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Passport\AuthCode;
|
||||
@@ -114,13 +118,28 @@ class DatabaseSeeder extends Seeder
|
||||
Tag::factory()->forOrganization($organizationAcme)->create([
|
||||
'name' => 'Code Review',
|
||||
]);
|
||||
Tag::factory()->forOrganization($organizationAcme)->create([
|
||||
$tagMeeting = Tag::factory()->forOrganization($organizationAcme)->create([
|
||||
'name' => 'Meeting',
|
||||
]);
|
||||
Tag::factory()->forOrganization($organizationAcme)->create([
|
||||
'name' => 'Research',
|
||||
]);
|
||||
|
||||
$workFilters = new GoalFiltersDto;
|
||||
$workFilters->timeEntryType = TimeEntryType::Work;
|
||||
Goal::factory()->forMember($userAcmeOwnerMember)->atLeast(6 * 3600)->period(GoalPeriod::Day)->filters($workFilters)->create([
|
||||
'name' => 'Track at least 6 hours per day',
|
||||
]);
|
||||
$meetingFilters = new GoalFiltersDto;
|
||||
$meetingFilters->setTagIds([$tagMeeting->getKey()]);
|
||||
$meetingFilters->timeEntryType = TimeEntryType::Work;
|
||||
Goal::factory()->forMember($userAcmeOwnerMember)->lessThan(5 * 3600)->period(GoalPeriod::Week)->filters($meetingFilters)->create([
|
||||
'name' => 'Less than 5 hours of meetings per week',
|
||||
]);
|
||||
Goal::factory()->forMember($userAcmeEmployeeMember)->atLeast(30 * 3600)->period(GoalPeriod::Week)->filters($workFilters)->create([
|
||||
'name' => 'Track 30 hours per week',
|
||||
]);
|
||||
|
||||
TimeEntry::factory()
|
||||
->count(10)
|
||||
->forMember($userAcmeAdminMember)
|
||||
@@ -219,6 +238,7 @@ class DatabaseSeeder extends Seeder
|
||||
// Application tables
|
||||
DB::table((new Audit)->getTable())->delete();
|
||||
DB::table((new Report)->getTable())->delete();
|
||||
DB::table((new Goal)->getTable())->delete();
|
||||
DB::table((new TimeEntry)->getTable())->delete();
|
||||
DB::table((new Task)->getTable())->delete();
|
||||
DB::table((new Tag)->getTable())->delete();
|
||||
|
||||
@@ -292,6 +292,16 @@ test.describe('Command Palette', () => {
|
||||
await expect(page.locator('[role="dialog"]').getByText('Create Tags')).toBeVisible();
|
||||
});
|
||||
|
||||
test('opens create goal modal', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
await openCommandPalette(page);
|
||||
await searchInCommandPalette(page, 'Create Goal');
|
||||
await selectCommand(page, 'Create Goal');
|
||||
await expect(
|
||||
page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Goal' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('opens invite member modal', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
await openCommandPalette(page);
|
||||
@@ -466,6 +476,9 @@ test.describe('Employee Command Palette Restrictions', () => {
|
||||
employee.page.getByRole('option', { name: 'Invite Member' })
|
||||
).not.toBeVisible();
|
||||
|
||||
// Employees can set personal goals for themselves
|
||||
await expect(employee.page.getByRole('option', { name: 'Create Goal' })).toBeVisible();
|
||||
|
||||
// Should still see Create Time Entry (employees can create time entries)
|
||||
await expect(
|
||||
employee.page.getByRole('option', { name: 'Create Time Entry' })
|
||||
|
||||
688
e2e/goals.spec.ts
Normal file
688
e2e/goals.spec.ts
Normal file
@@ -0,0 +1,688 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import {
|
||||
createClientViaApi,
|
||||
createGoalViaApi,
|
||||
createProjectViaApi,
|
||||
createProjectMemberViaApi,
|
||||
setupTestContext,
|
||||
createRunningTimeEntryWithStartViaApi,
|
||||
createTagViaApi,
|
||||
createTaskViaApi,
|
||||
createTimeEntryViaApi,
|
||||
deleteClientViaApi,
|
||||
deleteProjectViaApi,
|
||||
deleteTagViaApi,
|
||||
deleteTaskViaApi,
|
||||
updateGoalViaApi,
|
||||
updateOrganizationSettingViaApi,
|
||||
updateUserProfileViaApi,
|
||||
} from './utils/api';
|
||||
import {
|
||||
selectTimezone,
|
||||
selectWeekStart,
|
||||
timezoneField,
|
||||
weekStartField,
|
||||
} from './utils/userSettingsFields';
|
||||
|
||||
async function goToGoalsOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/goals');
|
||||
await expect(page.getByTestId('goals_view')).toBeVisible();
|
||||
}
|
||||
|
||||
function goalRow(page: Page, goalId: string) {
|
||||
return page.getByTestId('goal_row_' + goalId);
|
||||
}
|
||||
|
||||
function goalResponse(page: Page, method: 'POST' | 'PUT' | 'DELETE', status: number) {
|
||||
return page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/goals') &&
|
||||
response.request().method() === method &&
|
||||
response.status() === status
|
||||
);
|
||||
}
|
||||
|
||||
test('test that the goals page shows an empty state and the sidebar link works', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
await page.getByRole('link', { name: 'Goals' }).first().click();
|
||||
await expect(page).toHaveURL(/\/goals$/);
|
||||
await expect(page.getByTestId('goal_table')).toContainText('No goals found');
|
||||
await expect(page.getByRole('button', { name: 'Create your first goal' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that creating a goal via the modal works and shows zero progress', async ({ page }) => {
|
||||
const goalName = 'Deep work ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToGoalsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Goal' }).click();
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill(goalName);
|
||||
await page.getByTestId('duration_seconds_input').fill('10h');
|
||||
await page.getByTestId('duration_seconds_input').press('Tab');
|
||||
await page.getByRole('combobox', { name: 'Period' }).click();
|
||||
await page.getByRole('option', { name: 'per day' }).click();
|
||||
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'POST', 201),
|
||||
page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
|
||||
]);
|
||||
const body = await response.json();
|
||||
expect(body.data.name).toBe(goalName);
|
||||
expect(body.data.target_seconds).toBe(36000);
|
||||
expect(body.data.period).toBe('day');
|
||||
expect(body.data.comparison).toBe('at_least');
|
||||
expect(body.data.filters.time_entry_type).toBe('work');
|
||||
expect(body.data.type).toBe('personal');
|
||||
expect(body.data.is_archived).toBe(false);
|
||||
|
||||
const row = goalRow(page, body.data.id);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.getByTestId('goal_name')).toHaveText(goalName);
|
||||
await expect(row.getByTestId('goal_target_description')).toHaveText(
|
||||
'At least 10h 00min per day'
|
||||
);
|
||||
await expect(row.getByTestId('goal_tracked_time')).toHaveText('0h 00min');
|
||||
await expect(row).toContainText('of 10h 00min');
|
||||
await expect(row).toContainText('In progress');
|
||||
});
|
||||
|
||||
test('test that the create goal modal validates name and target', async ({ page }) => {
|
||||
await goToGoalsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Goal' }).click();
|
||||
await page.getByRole('button', { name: 'Create Goal', exact: true }).last().click();
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'name' })).toBeVisible();
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'target time' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that goal progress reflects tracked time entries that match the filters', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const project = await createProjectViaApi(ctx, { name: 'Goal Project', is_public: true });
|
||||
const otherProject = await createProjectViaApi(ctx, { name: 'Other Project', is_public: true });
|
||||
await createTimeEntryViaApi(ctx, { duration: '2h', projectId: project.id });
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h 30min', projectId: otherProject.id });
|
||||
await createTimeEntryViaApi(ctx, { duration: '45min' });
|
||||
|
||||
const projectGoal = await createGoalViaApi(ctx, {
|
||||
name: 'Project goal',
|
||||
target_seconds: 4 * 3600,
|
||||
period: 'day',
|
||||
filters: { project_ids: [project.id], time_entry_type: 'work' },
|
||||
});
|
||||
const allGoal = await createGoalViaApi(ctx, {
|
||||
name: 'All time goal',
|
||||
target_seconds: 4 * 3600,
|
||||
period: 'day',
|
||||
});
|
||||
const lessThanGoal = await createGoalViaApi(ctx, {
|
||||
name: 'Less than goal',
|
||||
comparison: 'less_than',
|
||||
target_seconds: 3600,
|
||||
period: 'day',
|
||||
filters: { project_ids: [project.id], time_entry_type: 'work' },
|
||||
});
|
||||
|
||||
expect(projectGoal.progress.tracked_seconds).toBe(2 * 3600);
|
||||
expect(allGoal.progress.tracked_seconds).toBe(2 * 3600 + 90 * 60 + 45 * 60);
|
||||
expect(lessThanGoal.progress.status).toBe('exceeded');
|
||||
|
||||
await goToGoalsOverview(page);
|
||||
await expect(goalRow(page, projectGoal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
'2h 00min'
|
||||
);
|
||||
await expect(goalRow(page, projectGoal.id)).toContainText('Goal Project');
|
||||
await expect(goalRow(page, projectGoal.id)).toContainText('In progress');
|
||||
await expect(goalRow(page, allGoal.id).getByTestId('goal_tracked_time')).toHaveText('4h 15min');
|
||||
await expect(goalRow(page, allGoal.id)).toContainText('Achieved');
|
||||
await expect(goalRow(page, allGoal.id)).toContainText('All time entries');
|
||||
await expect(goalRow(page, lessThanGoal.id)).toContainText('Exceeded');
|
||||
await expect(goalRow(page, lessThanGoal.id)).toContainText('Less than 1h 00min per day');
|
||||
});
|
||||
|
||||
async function openCreateGoalModal(page: Page, name: string) {
|
||||
await goToGoalsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Goal' }).click();
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill(name);
|
||||
await page.getByTestId('duration_seconds_input').fill('10h');
|
||||
await page.getByTestId('duration_seconds_input').press('Tab');
|
||||
}
|
||||
|
||||
async function submitCreateGoalModal(page: Page) {
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'POST', 201),
|
||||
page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
|
||||
]);
|
||||
return (await response.json()).data;
|
||||
}
|
||||
|
||||
async function selectGoalFilterOption(
|
||||
page: Page,
|
||||
filter: string,
|
||||
searchPlaceholder: string,
|
||||
option: string
|
||||
) {
|
||||
await page.getByTestId('goal_filter_' + filter).click();
|
||||
await page.getByRole('option', { name: option }).click();
|
||||
await page.getByPlaceholder(searchPlaceholder).press('Escape');
|
||||
}
|
||||
|
||||
test('test that the task filter of a goal only counts entries of the selected tasks', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const project = await createProjectViaApi(ctx, {
|
||||
name: 'Task filter project',
|
||||
is_public: true,
|
||||
});
|
||||
const task = await createTaskViaApi(ctx, { name: 'Counted task', project_id: project.id });
|
||||
const otherTask = await createTaskViaApi(ctx, { name: 'Other task', project_id: project.id });
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h', projectId: project.id, taskId: task.id });
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
duration: '2h',
|
||||
projectId: project.id,
|
||||
taskId: otherTask.id,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, { duration: '30min', projectId: project.id });
|
||||
|
||||
await openCreateGoalModal(page, 'Task goal');
|
||||
await selectGoalFilterOption(page, 'task_ids', 'Search for a Task...', 'Counted task');
|
||||
await expect(page.getByTestId('goal_filter_task_ids')).toHaveText(/Tasks\s*1/);
|
||||
const goal = await submitCreateGoalModal(page);
|
||||
|
||||
expect(goal.filters.task_ids).toEqual([task.id]);
|
||||
expect(goal.progress.tracked_seconds).toBe(3600);
|
||||
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('1h 00min');
|
||||
});
|
||||
|
||||
test('test that the client filter of a goal only counts entries of projects of the selected clients', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const client = await createClientViaApi(ctx, { name: 'Counted client' });
|
||||
const otherClient = await createClientViaApi(ctx, { name: 'Other client' });
|
||||
const project = await createProjectViaApi(ctx, {
|
||||
name: 'Client project',
|
||||
client_id: client.id,
|
||||
is_public: true,
|
||||
});
|
||||
const otherProject = await createProjectViaApi(ctx, {
|
||||
name: 'Other client project',
|
||||
client_id: otherClient.id,
|
||||
is_public: true,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h', projectId: project.id });
|
||||
await createTimeEntryViaApi(ctx, { duration: '2h', projectId: otherProject.id });
|
||||
await createTimeEntryViaApi(ctx, { duration: '30min' });
|
||||
|
||||
await openCreateGoalModal(page, 'Client goal');
|
||||
await selectGoalFilterOption(page, 'client_ids', 'Search for a Client...', 'Counted client');
|
||||
await expect(page.getByTestId('goal_filter_client_ids')).toHaveText(/Clients\s*1/);
|
||||
const goal = await submitCreateGoalModal(page);
|
||||
|
||||
expect(goal.filters.client_ids).toEqual([client.id]);
|
||||
expect(goal.progress.tracked_seconds).toBe(3600);
|
||||
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('1h 00min');
|
||||
});
|
||||
|
||||
test('test that the tag filter of a goal respects the tag match type', async ({ page, ctx }) => {
|
||||
const tag = await createTagViaApi(ctx, { name: 'Counted tag' });
|
||||
const otherTag = await createTagViaApi(ctx, { name: 'Other tag' });
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h', tags: [tag.id] });
|
||||
await createTimeEntryViaApi(ctx, { duration: '2h', tags: [otherTag.id] });
|
||||
await createTimeEntryViaApi(ctx, { duration: '30min' });
|
||||
|
||||
await openCreateGoalModal(page, 'Contains tag goal');
|
||||
await selectGoalFilterOption(page, 'tag_ids', 'Search for a Tag...', 'Counted tag');
|
||||
await expect(page.getByTestId('goal_filter_tag_ids')).toHaveText(/Tags\s*1/);
|
||||
const containsGoal = await submitCreateGoalModal(page);
|
||||
expect(containsGoal.filters.tag_ids).toEqual([tag.id]);
|
||||
expect(containsGoal.filters.tag_match_type).toBe('contains');
|
||||
expect(containsGoal.progress.tracked_seconds).toBe(3600);
|
||||
|
||||
await openCreateGoalModal(page, 'Not contains tag goal');
|
||||
await page.getByTestId('goal_filter_tag_ids').click();
|
||||
await page.getByRole('radio', { name: 'Does Not Contain' }).click();
|
||||
await page.getByRole('option', { name: 'Counted tag' }).click();
|
||||
await page.getByPlaceholder('Search for a Tag...').press('Escape');
|
||||
const notContainsGoal = await submitCreateGoalModal(page);
|
||||
expect(notContainsGoal.filters.tag_ids).toEqual([tag.id]);
|
||||
expect(notContainsGoal.filters.tag_match_type).toBe('not_contains');
|
||||
// Entries with other tags and entries without tags count
|
||||
expect(notContainsGoal.progress.tracked_seconds).toBe(2 * 3600 + 30 * 60);
|
||||
|
||||
await expect(goalRow(page, containsGoal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
'1h 00min'
|
||||
);
|
||||
await expect(goalRow(page, notContainsGoal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
'2h 30min'
|
||||
);
|
||||
});
|
||||
|
||||
test('test that the billable filter of a goal only counts entries with the selected billable status', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h', billable: true });
|
||||
await createTimeEntryViaApi(ctx, { duration: '2h', billable: false });
|
||||
|
||||
await openCreateGoalModal(page, 'Billable goal');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
|
||||
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||
const billableGoal = await submitCreateGoalModal(page);
|
||||
expect(billableGoal.filters.billable).toBe(true);
|
||||
expect(billableGoal.progress.tracked_seconds).toBe(3600);
|
||||
|
||||
await openCreateGoalModal(page, 'Non billable goal');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
|
||||
await page.getByRole('option', { name: 'Non Billable' }).click();
|
||||
const nonBillableGoal = await submitCreateGoalModal(page);
|
||||
expect(nonBillableGoal.filters.billable).toBe(false);
|
||||
expect(nonBillableGoal.progress.tracked_seconds).toBe(2 * 3600);
|
||||
|
||||
await expect(goalRow(page, billableGoal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
'1h 00min'
|
||||
);
|
||||
await expect(goalRow(page, nonBillableGoal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
'2h 00min'
|
||||
);
|
||||
});
|
||||
|
||||
test('test that the type filter of a goal only counts entries of the selected type', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||||
await createTimeEntryViaApi(ctx, { duration: '1h' });
|
||||
await createTimeEntryViaApi(ctx, { duration: '30min', type: 'break' });
|
||||
|
||||
await openCreateGoalModal(page, 'Break goal');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Work time' }).click();
|
||||
await page.getByRole('option', { name: 'Breaks' }).click();
|
||||
const breakGoal = await submitCreateGoalModal(page);
|
||||
expect(breakGoal.filters.time_entry_type).toBe('break');
|
||||
expect(breakGoal.progress.tracked_seconds).toBe(30 * 60);
|
||||
|
||||
await openCreateGoalModal(page, 'Work and break goal');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Work time' }).click();
|
||||
await page.getByRole('option', { name: 'Both' }).click();
|
||||
const bothGoal = await submitCreateGoalModal(page);
|
||||
expect(bothGoal.filters.time_entry_type).toBeNull();
|
||||
expect(bothGoal.progress.tracked_seconds).toBe(3600 + 30 * 60);
|
||||
|
||||
await expect(goalRow(page, breakGoal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
'0h 30min'
|
||||
);
|
||||
await expect(goalRow(page, bothGoal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
'1h 30min'
|
||||
);
|
||||
});
|
||||
|
||||
test('test that a running time entry counts towards the goal progress', async ({ page, ctx }) => {
|
||||
const start = new Date(Date.now() - 30 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
await createRunningTimeEntryWithStartViaApi(ctx, 'Running work', start);
|
||||
// Close to midnight UTC the entry would start on the previous day, so the day of the goal
|
||||
// is moved to a timezone (without daylight saving time) where it is late morning instead
|
||||
const utcHour = new Date().getUTCHours();
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Running goal',
|
||||
target_seconds: 3600,
|
||||
period: 'day',
|
||||
timezone: utcHour >= 2 && utcHour < 22 ? 'UTC' : 'Asia/Tokyo',
|
||||
});
|
||||
expect(goal.progress.tracked_seconds).toBeGreaterThanOrEqual(30 * 60);
|
||||
|
||||
await goToGoalsOverview(page);
|
||||
// The entry keeps running while the page loads
|
||||
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||
/^0h 3[0-2]min$/
|
||||
);
|
||||
});
|
||||
|
||||
test('test that editing a goal via the modal works', async ({ page, ctx }) => {
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Old goal name',
|
||||
target_seconds: 3600,
|
||||
period: 'week',
|
||||
});
|
||||
await goToGoalsOverview(page);
|
||||
await page.locator("[aria-label='Actions for Goal Old goal name']").click();
|
||||
await page.locator("[aria-label='Edit Goal Old goal name']").click();
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill('New goal name');
|
||||
await page.getByRole('combobox', { name: 'Target type' }).click();
|
||||
await page.getByRole('option', { name: 'Less than' }).click();
|
||||
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'PUT', 200),
|
||||
page.getByRole('button', { name: 'Update Goal' }).click(),
|
||||
]);
|
||||
const body = await response.json();
|
||||
expect(body.data.name).toBe('New goal name');
|
||||
expect(body.data.comparison).toBe('less_than');
|
||||
|
||||
const row = goalRow(page, goal.id);
|
||||
await expect(row.getByTestId('goal_name')).toHaveText('New goal name');
|
||||
await expect(row).toContainText('Less than 1h 00min per week');
|
||||
await expect(row).toContainText('On track');
|
||||
});
|
||||
|
||||
test('test that a goal whose filters point to deleted entities can still be edited', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const keptProject = await createProjectViaApi(ctx, { name: 'Kept project' });
|
||||
const deletedProject = await createProjectViaApi(ctx, { name: 'Deleted project' });
|
||||
const keptTask = await createTaskViaApi(ctx, { name: 'Kept task', project_id: keptProject.id });
|
||||
const deletedTask = await createTaskViaApi(ctx, {
|
||||
name: 'Deleted task',
|
||||
project_id: keptProject.id,
|
||||
});
|
||||
const keptTag = await createTagViaApi(ctx, { name: 'Kept tag' });
|
||||
const deletedTag = await createTagViaApi(ctx, { name: 'Deleted tag' });
|
||||
const deletedClient = await createClientViaApi(ctx, { name: 'Deleted client' });
|
||||
const filters = {
|
||||
project_ids: [keptProject.id, deletedProject.id],
|
||||
task_ids: [keptTask.id, deletedTask.id],
|
||||
// "none" is no entity, it has to survive the cleanup
|
||||
tag_ids: ['none', keptTag.id, deletedTag.id],
|
||||
// Every client of this filter gets deleted
|
||||
client_ids: [deletedClient.id],
|
||||
time_entry_type: 'work' as const,
|
||||
};
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Stale filters',
|
||||
target_seconds: 3600,
|
||||
filters,
|
||||
});
|
||||
await deleteTaskViaApi(ctx, deletedTask.id);
|
||||
await deleteProjectViaApi(ctx, deletedProject.id);
|
||||
await deleteTagViaApi(ctx, deletedTag.id);
|
||||
await deleteClientViaApi(ctx, deletedClient.id);
|
||||
|
||||
// Resending deleted IDs fails; unrelated edits can omit filters to preserve the scope.
|
||||
const rejected = await updateGoalViaApi(ctx, goal.id, { filters });
|
||||
expect(rejected.status()).toBe(422);
|
||||
const errors = (await rejected.json()).errors;
|
||||
expect(Object.keys(errors).sort()).toEqual([
|
||||
'filters.client_ids.0',
|
||||
'filters.project_ids.1',
|
||||
'filters.tag_ids.2',
|
||||
'filters.task_ids.1',
|
||||
]);
|
||||
|
||||
await goToGoalsOverview(page);
|
||||
await page.locator("[aria-label='Actions for Goal Stale filters']").click();
|
||||
await page.locator("[aria-label='Edit Goal Stale filters']").click();
|
||||
// Opening the form must preserve the scope, even after lists have loaded.
|
||||
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*2/);
|
||||
await expect(page.getByTestId('goal_filter_task_ids')).toHaveText(/Tasks\s*2/);
|
||||
await expect(page.getByTestId('goal_filter_tag_ids')).toHaveText(/Tags\s*3/);
|
||||
await expect(page.getByTestId('goal_filter_client_ids')).toHaveText(/Clients\s*1/);
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Preserved filters');
|
||||
await page.getByRole('button', { name: 'Update Goal' }).click();
|
||||
await expect(page.getByTestId('goal_unavailable_filters')).toContainText(
|
||||
'This will count all clients'
|
||||
);
|
||||
const [preservedResponse] = await Promise.all([
|
||||
goalResponse(page, 'PUT', 200),
|
||||
page.getByRole('button', { name: 'Keep existing filters' }).click(),
|
||||
]);
|
||||
expect(preservedResponse.request().postDataJSON()).not.toHaveProperty('filters');
|
||||
expect((await preservedResponse.json()).data.filters).toMatchObject(filters);
|
||||
|
||||
await page.locator("[aria-label='Actions for Goal Preserved filters']").click();
|
||||
await page.locator("[aria-label='Edit Goal Preserved filters']").click();
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Cleaned filters');
|
||||
await page.getByRole('button', { name: 'Update Goal' }).click();
|
||||
await expect(page.getByTestId('goal_unavailable_filters')).toBeVisible();
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'PUT', 200),
|
||||
page.getByRole('button', { name: 'Remove unavailable items' }).click(),
|
||||
]);
|
||||
const body = await response.json();
|
||||
expect(body.data.name).toBe('Cleaned filters');
|
||||
expect(body.data.filters.project_ids).toEqual([keptProject.id]);
|
||||
expect(body.data.filters.task_ids).toEqual([keptTask.id]);
|
||||
expect(body.data.filters.tag_ids).toEqual(['none', keptTag.id]);
|
||||
// A filter whose entities are all gone is dropped, the goal no longer filters by client
|
||||
expect(body.data.filters.client_ids).toBeNull();
|
||||
await expect(goalRow(page, goal.id).getByTestId('goal_name')).toHaveText('Cleaned filters');
|
||||
});
|
||||
|
||||
test('losing access to a private project warns without silently changing a personal goal', async ({
|
||||
ctx,
|
||||
employee,
|
||||
}) => {
|
||||
test.setTimeout(120 * 1000);
|
||||
const project = await createProjectViaApi(ctx, {
|
||||
name: 'Private goal project',
|
||||
is_public: false,
|
||||
});
|
||||
const membership = await createProjectMemberViaApi(ctx, project.id, {
|
||||
member_id: employee.memberId,
|
||||
});
|
||||
const employeeCtx = {
|
||||
...(await setupTestContext(employee.page)),
|
||||
orgId: ctx.orgId,
|
||||
memberId: employee.memberId,
|
||||
};
|
||||
const goal = await createGoalViaApi(employeeCtx, {
|
||||
name: 'Private project goal',
|
||||
target_seconds: 3600,
|
||||
filters: { project_ids: [project.id], time_entry_type: 'work' },
|
||||
});
|
||||
const removed = await ctx.request.delete(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/project-members/${membership.id}`
|
||||
);
|
||||
expect(removed.status()).toBe(204);
|
||||
// The project still exists, but the employee's selectable list no longer contains it.
|
||||
const projects = await employeeCtx.request.get(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects?archived=all`
|
||||
);
|
||||
expect(projects.status()).toBe(200);
|
||||
expect((await projects.json()).data.map((item: { id: string }) => item.id)).not.toContain(
|
||||
project.id
|
||||
);
|
||||
|
||||
const page = employee.page;
|
||||
await goToGoalsOverview(page);
|
||||
await page.locator("[aria-label='Actions for Goal Private project goal']").click();
|
||||
await page.locator("[aria-label='Edit Goal Private project goal']").click();
|
||||
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/);
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Still scoped');
|
||||
await page.getByRole('button', { name: 'Update Goal' }).click();
|
||||
await expect(page.getByTestId('goal_unavailable_filters')).toContainText(
|
||||
'Unavailable projects: 1'
|
||||
);
|
||||
await expect(page.getByTestId('goal_unavailable_filters')).toContainText(
|
||||
'This will count all projects'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click();
|
||||
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/);
|
||||
await page.getByRole('button', { name: 'Update Goal' }).click();
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'PUT', 200),
|
||||
page.getByRole('button', { name: 'Keep existing filters' }).click(),
|
||||
]);
|
||||
expect(response.request().postDataJSON()).not.toHaveProperty('filters');
|
||||
expect((await response.json()).data.filters.project_ids).toEqual([project.id]);
|
||||
await expect(goalRow(page, goal.id).getByTestId('goal_name')).toHaveText('Still scoped');
|
||||
});
|
||||
|
||||
test('test that archiving a goal moves it to the archived tab and back', async ({ page, ctx }) => {
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Goal to archive',
|
||||
target_seconds: 3600,
|
||||
});
|
||||
await goToGoalsOverview(page);
|
||||
await expect(goalRow(page, goal.id)).toBeVisible();
|
||||
|
||||
await page.locator("[aria-label='Actions for Goal Goal to archive']").click();
|
||||
await Promise.all([
|
||||
goalResponse(page, 'PUT', 200),
|
||||
page.locator("[aria-label='Archive Goal Goal to archive']").click(),
|
||||
]);
|
||||
await expect(goalRow(page, goal.id)).not.toBeVisible();
|
||||
await expect(page.getByTestId('goal_table')).toContainText('No goals found');
|
||||
|
||||
await page.getByRole('tab', { name: 'Archived' }).click();
|
||||
const archivedRow = goalRow(page, goal.id);
|
||||
await expect(archivedRow).toBeVisible();
|
||||
await expect(archivedRow.getByTestId('goal_archived_badge')).toBeVisible();
|
||||
|
||||
await page.locator("[aria-label='Actions for Goal Goal to archive']").click();
|
||||
await Promise.all([
|
||||
goalResponse(page, 'PUT', 200),
|
||||
page.locator("[aria-label='Unarchive Goal Goal to archive']").click(),
|
||||
]);
|
||||
await expect(goalRow(page, goal.id)).not.toBeVisible();
|
||||
await page.getByRole('tab', { name: 'Active' }).click();
|
||||
await expect(goalRow(page, goal.id)).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that the period settings of a goal can be changed in the accordion', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Goal with timezone',
|
||||
target_seconds: 3600,
|
||||
period: 'week',
|
||||
timezone: 'Asia/Tokyo',
|
||||
week_start: 'friday',
|
||||
});
|
||||
await goToGoalsOverview(page);
|
||||
await page.locator("[aria-label='Actions for Goal Goal with timezone']").click();
|
||||
await page.locator("[aria-label='Edit Goal Goal with timezone']").click();
|
||||
await page.getByTestId('goal_period_settings').click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(timezoneField(dialog)).toHaveText('Asia/Tokyo');
|
||||
await expect(weekStartField(dialog)).toHaveText('Friday');
|
||||
|
||||
await selectTimezone(page, dialog, 'America/New_York', 'new york');
|
||||
await selectWeekStart(page, dialog, 'Sunday');
|
||||
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'PUT', 200),
|
||||
page.getByRole('button', { name: 'Update Goal' }).click(),
|
||||
]);
|
||||
const body = await response.json();
|
||||
expect(body.data.id).toBe(goal.id);
|
||||
expect(body.data.timezone).toBe('America/New_York');
|
||||
expect(body.data.week_start).toBe('sunday');
|
||||
});
|
||||
|
||||
test('test that the period settings of a new goal default to the user settings', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Same UTC offset as the browser, so the timezone mismatch modal stays closed
|
||||
await updateUserProfileViaApi(ctx, { timezone: 'Atlantic/Reykjavik', week_start: 'wednesday' });
|
||||
await goToGoalsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Goal' }).click();
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Goal with defaults');
|
||||
await page.getByTestId('duration_seconds_input').fill('10h');
|
||||
await page.getByTestId('duration_seconds_input').press('Tab');
|
||||
await page.getByTestId('goal_period_settings').click();
|
||||
await expect(timezoneField(page.getByRole('dialog'))).toHaveText('Atlantic/Reykjavik');
|
||||
await expect(weekStartField(page.getByRole('dialog'))).toHaveText('Wednesday');
|
||||
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'POST', 201),
|
||||
page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
|
||||
]);
|
||||
const body = await response.json();
|
||||
expect(body.data.timezone).toBe('Atlantic/Reykjavik');
|
||||
expect(body.data.week_start).toBe('wednesday');
|
||||
});
|
||||
|
||||
test('test that the period settings of a new goal can be set in the create modal', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToGoalsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Goal' }).click();
|
||||
await page.getByPlaceholder('e.g. Deep work on Project X').fill('Goal in New York');
|
||||
await page.getByTestId('duration_seconds_input').fill('10h');
|
||||
await page.getByTestId('duration_seconds_input').press('Tab');
|
||||
await page.getByTestId('goal_period_settings').click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await selectTimezone(page, dialog, 'America/New_York', 'new york');
|
||||
await selectWeekStart(page, dialog, 'Saturday');
|
||||
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(page, 'POST', 201),
|
||||
page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
|
||||
]);
|
||||
const body = await response.json();
|
||||
expect(body.data.name).toBe('Goal in New York');
|
||||
expect(body.data.timezone).toBe('America/New_York');
|
||||
expect(body.data.week_start).toBe('saturday');
|
||||
});
|
||||
|
||||
test('test that the search of a goal filter can be used with the keyboard', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await createProjectViaApi(ctx, { name: 'Alpha keyboard project', is_public: true });
|
||||
await createProjectViaApi(ctx, { name: 'Beta keyboard project', is_public: true });
|
||||
await goToGoalsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Goal' }).click();
|
||||
await page.getByTestId('goal_filter_project_ids').click();
|
||||
|
||||
const search = page.getByPlaceholder('Search for a Project...');
|
||||
await search.click();
|
||||
await search.pressSequentially('alpha');
|
||||
await expect(search).toHaveValue('alpha');
|
||||
await expect(page.getByRole('option', { name: 'Beta keyboard project' })).toBeHidden();
|
||||
|
||||
// The arrow keys highlight an option while the focus stays in the search
|
||||
const alpha = page.getByRole('option', { name: 'Alpha keyboard project' });
|
||||
await search.press('ArrowDown');
|
||||
await expect(alpha).toHaveAttribute('data-highlighted', '');
|
||||
await search.press('Enter');
|
||||
await expect(search).toBeFocused();
|
||||
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/);
|
||||
|
||||
// Escape closes the dropdown but keeps the modal and the selection
|
||||
await search.press('Escape');
|
||||
await expect(search).toBeHidden();
|
||||
await expect(page.getByPlaceholder('e.g. Deep work on Project X')).toBeVisible();
|
||||
await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/);
|
||||
});
|
||||
|
||||
test('test that deleting a goal via the more options dropdown works', async ({ page, ctx }) => {
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Goal to delete',
|
||||
target_seconds: 3600,
|
||||
});
|
||||
await goToGoalsOverview(page);
|
||||
await expect(goalRow(page, goal.id)).toBeVisible();
|
||||
await page.locator("[aria-label='Actions for Goal Goal to delete']").click();
|
||||
await Promise.all([
|
||||
goalResponse(page, 'DELETE', 204),
|
||||
page.locator("[aria-label='Delete Goal Goal to delete']").click(),
|
||||
]);
|
||||
await expect(goalRow(page, goal.id)).not.toBeVisible();
|
||||
await expect(page.getByTestId('goal_table')).toContainText('No goals found');
|
||||
});
|
||||
|
||||
test.describe('employee', () => {
|
||||
test('employee can create and see their own goals', async ({ employee }) => {
|
||||
await goToGoalsOverview(employee.page);
|
||||
await employee.page.getByRole('button', { name: 'Create Goal' }).click();
|
||||
await employee.page.getByPlaceholder('e.g. Deep work on Project X').fill('Employee goal');
|
||||
await employee.page.getByTestId('duration_seconds_input').fill('2h');
|
||||
await employee.page.getByTestId('duration_seconds_input').press('Tab');
|
||||
const [response] = await Promise.all([
|
||||
goalResponse(employee.page, 'POST', 201),
|
||||
employee.page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(),
|
||||
]);
|
||||
const body = await response.json();
|
||||
expect(body.data.type).toBe('personal');
|
||||
expect(body.data.member_id).toBe(employee.memberId);
|
||||
await expect(goalRow(employee.page, body.data.id)).toContainText('Employee goal');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
} from './utils/mailpit';
|
||||
import { getCurrentUserViaApi } from './utils/api';
|
||||
import { registerUser } from './utils/members';
|
||||
import {
|
||||
selectTimezone,
|
||||
selectWeekStart,
|
||||
timezoneField,
|
||||
weekStartField,
|
||||
} from './utils/userSettingsFields';
|
||||
import type { Page } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
@@ -40,20 +46,51 @@ test('user name can be updated', async ({ page }) => {
|
||||
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME');
|
||||
});
|
||||
|
||||
// The tests below pick UTC+0 timezones: the browser runs in UTC, and any other offset
|
||||
// opens the timezone mismatch modal after the reload.
|
||||
test('timezone change persists across reload', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
await page.getByLabel('Timezone').selectOption('America/New_York');
|
||||
await selectTimezone(page, page, 'Africa/Sao_Tome', 'sao tome');
|
||||
await saveProfileForm(page);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Timezone')).toHaveValue('America/New_York');
|
||||
await expect(timezoneField(page)).toHaveText('Africa/Sao_Tome');
|
||||
});
|
||||
|
||||
test('timezone can be selected with the keyboard', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
await expect(timezoneField(page)).toBeEnabled();
|
||||
await timezoneField(page).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const search = page.getByRole('combobox', { name: 'Search timezones' });
|
||||
await expect(search).toBeFocused();
|
||||
await search.fill('Africa/Abid');
|
||||
await expect(page.getByRole('option')).toHaveText(['Africa/Abidjan']);
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(timezoneField(page)).toHaveText('Africa/Abidjan');
|
||||
await saveProfileForm(page);
|
||||
await page.reload();
|
||||
await expect(timezoneField(page)).toHaveText('Africa/Abidjan');
|
||||
});
|
||||
|
||||
test('timezone search shows an empty state when nothing matches', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
await timezoneField(page).click();
|
||||
const search = page.getByRole('combobox', { name: 'Search timezones' });
|
||||
// Opening starts with an empty search, not one prefilled with the current timezone
|
||||
await expect(search).toHaveValue('');
|
||||
await expect(page.getByRole('option').nth(1)).toBeVisible();
|
||||
await search.fill('Atlantis');
|
||||
await expect(page.getByText('No timezone found.')).toBeVisible();
|
||||
await expect(page.getByRole('option')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('week-start change persists across reload', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
await page.getByLabel('Start of the week').selectOption('sunday');
|
||||
await selectWeekStart(page, page, 'Sunday');
|
||||
await saveProfileForm(page);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
|
||||
await expect(weekStartField(page)).toHaveText('Sunday');
|
||||
});
|
||||
|
||||
test('still-running email notification setting persists across reload', async ({ page }) => {
|
||||
@@ -565,7 +602,10 @@ test('test that theme can be changed to dark and light', async ({ page }) => {
|
||||
await goToProfilePage(page);
|
||||
|
||||
// The theme select is a Reka UI combobox (button), not a native <select>
|
||||
const themeSelect = page.locator('button[role="combobox"]');
|
||||
const themeSelect = page
|
||||
.getByRole('heading', { name: 'Theme', exact: true })
|
||||
.locator('xpath=ancestor::*[descendant::form][1]')
|
||||
.getByRole('combobox');
|
||||
|
||||
// Change theme to dark
|
||||
await themeSelect.click();
|
||||
@@ -591,7 +631,7 @@ test('test that theme can be changed to dark and light', async ({ page }) => {
|
||||
await expect(page.locator('html')).toHaveClass(/light/);
|
||||
|
||||
// Reset to system
|
||||
await page.locator('button[role="combobox"]').click();
|
||||
await themeSelect.click();
|
||||
await page.getByRole('option', { name: 'System' }).click();
|
||||
await expect(page.getByText('System default:')).toBeVisible();
|
||||
});
|
||||
|
||||
110
e2e/utils/api.ts
110
e2e/utils/api.ts
@@ -990,3 +990,113 @@ export async function getInvoicesViaApi(ctx: TestContext) {
|
||||
paid_date: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function createGoalViaApi(
|
||||
ctx: TestContext,
|
||||
data: {
|
||||
name: string;
|
||||
type?: 'personal' | 'organization';
|
||||
comparison?: 'at_least' | 'less_than';
|
||||
target_seconds: number;
|
||||
period?: 'day' | 'week' | 'month';
|
||||
// Member the goal is for, null counts every member (organization goals only)
|
||||
member_id?: string | null;
|
||||
timezone?: string;
|
||||
week_start?: string;
|
||||
filters?: {
|
||||
member_ids?: string[] | null;
|
||||
project_ids?: string[] | null;
|
||||
task_ids?: string[] | null;
|
||||
tag_ids?: string[] | null;
|
||||
tag_match_type?: 'contains' | 'not_contains' | null;
|
||||
client_ids?: string[] | null;
|
||||
billable?: boolean | null;
|
||||
time_entry_type?: 'work' | 'break' | null;
|
||||
};
|
||||
}
|
||||
) {
|
||||
const response = await ctx.request.post(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/goals`,
|
||||
{
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type ?? 'personal',
|
||||
comparison: data.comparison ?? 'at_least',
|
||||
target_seconds: data.target_seconds,
|
||||
period: data.period ?? 'week',
|
||||
...('member_id' in data ? { member_id: data.member_id } : {}),
|
||||
...(data.timezone ? { timezone: data.timezone } : {}),
|
||||
...(data.week_start ? { week_start: data.week_start } : {}),
|
||||
filters: data.filters ?? { time_entry_type: 'work' },
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(response.status()).toBe(201);
|
||||
const body = await response.json();
|
||||
return body.data as {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'personal' | 'organization';
|
||||
comparison: 'at_least' | 'less_than';
|
||||
target_seconds: number;
|
||||
period: 'day' | 'week' | 'month';
|
||||
member_id: string | null;
|
||||
member_name: string | null;
|
||||
timezone: string;
|
||||
week_start: string;
|
||||
is_archived: boolean;
|
||||
progress: {
|
||||
tracked_seconds: number;
|
||||
status: 'in_progress' | 'achieved' | 'on_track' | 'exceeded';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateGoalViaApi(
|
||||
ctx: TestContext,
|
||||
goalId: string,
|
||||
data: Record<string, unknown>
|
||||
) {
|
||||
return ctx.request.put(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/goals/${goalId}`,
|
||||
{ data }
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Entity deletion
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
async function deleteEntityViaApi(ctx: TestContext, path: string) {
|
||||
const response = await ctx.request.delete(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/${path}`
|
||||
);
|
||||
expect(response.status()).toBe(204);
|
||||
}
|
||||
|
||||
export async function deleteProjectViaApi(ctx: TestContext, projectId: string) {
|
||||
await deleteEntityViaApi(ctx, `projects/${projectId}`);
|
||||
}
|
||||
|
||||
export async function deleteTaskViaApi(ctx: TestContext, taskId: string) {
|
||||
await deleteEntityViaApi(ctx, `tasks/${taskId}`);
|
||||
}
|
||||
|
||||
export async function deleteTagViaApi(ctx: TestContext, tagId: string) {
|
||||
await deleteEntityViaApi(ctx, `tags/${tagId}`);
|
||||
}
|
||||
|
||||
export async function deleteClientViaApi(ctx: TestContext, clientId: string) {
|
||||
await deleteEntityViaApi(ctx, `clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function deleteMemberViaApi(ctx: TestContext, memberId: string) {
|
||||
await deleteEntityViaApi(ctx, `members/${memberId}`);
|
||||
}
|
||||
|
||||
export async function makeMemberPlaceholderViaApi(ctx: TestContext, memberId: string) {
|
||||
const response = await ctx.request.post(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members/${memberId}/make-placeholder`
|
||||
);
|
||||
expect(response.status()).toBe(204);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user