Compare commits

..

1 Commits

Author SHA1 Message Date
Constantin Graf
b5c0da4c6a Add postgres 18 support 2026-09-24 11:32:35 +02:00
160 changed files with 462 additions and 9178 deletions

View File

@@ -8,7 +8,7 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
strategy: strategy:
matrix: matrix:
postgres_version: [ 15, 16, 17 ] postgres_version: [ 15, 16, 17, 18 ]
services: services:
pgsql_test: pgsql_test:

View File

@@ -8,7 +8,7 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
strategy: strategy:
matrix: matrix:
postgres_version: [ 15, 16, 17 ] postgres_version: [ 15, 16, 17, 18 ]
services: services:
pgsql_test: pgsql_test:

View File

@@ -1,185 +0,0 @@
name: Playwright Tests - Extensions
on: push
permissions:
contents: read
jobs:
test:
name: test (${{ matrix.variant }})
runs-on: ubuntu-latest
timeout-minutes: 60
# Same extension sets as build-private.yml and build-onpremise.yml, so the extension specs run
# with and without Billing.
strategy:
fail-fast: false
matrix:
include:
- variant: private
billing: true
extensions: Billing Services Invoicing
- variant: onpremise
billing: false
extensions: Invoicing
services:
mailpit:
image: 'axllent/mailpit:latest'
ports:
- 1025:1025
- 8025:8025
pgsql_test:
image: postgres:15
env:
PGPASSWORD: 'root'
POSTGRES_DB: 'laravel'
POSTGRES_USER: 'root'
POSTGRES_PASSWORD: 'root'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
gotenberg:
image: gotenberg/gotenberg:8
ports:
- 3000:3000
options: >-
--health-cmd "curl --silent --fail http://localhost:3000/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Setup node"
uses: actions/setup-node@v7
with:
node-version: '20.x'
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: none
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
if: matrix.billing
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
- name: "Install composer dependencies in billing extension"
if: matrix.billing
working-directory: extensions/Billing
env:
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "contact@solidtime.io", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in billing extension"
if: matrix.billing
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
if: matrix.billing
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
- name: "Install composer dependencies in services extension"
if: matrix.billing
working-directory: extensions/Services
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in services extension"
if: matrix.billing
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
working-directory: extensions/Invoicing
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Run composer install"
run: composer install -n --prefer-dist
# Must run before `migrate` so the extensions' migrations are applied, and before the
# frontend build so vite collects the extensions' assets. It also writes
# modules_statuses.json, which playwright.config.ts reads to collect the extension specs.
- name: "Activate extensions"
run: |
for extension in ${{ matrix.extensions }}; do
php artisan module:enable "$extension"
done
- name: "Prepare Laravel Application"
run: |
cp .env.ci .env
php artisan key:generate
php artisan passport:keys
php artisan migrate --seed
- name: "Install dependencies"
run: npm ci
- name: "Build Frontend"
run: npm run build
- name: "Install FrankenPHP"
run: |
ARCH="$(uname -m)"
curl -fsSL "https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-${ARCH}" -o /usr/local/bin/frankenphp
chmod +x /usr/local/bin/frankenphp
- name: "Run Laravel Octane Server"
run: php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000 --workers=4 --max-requests=500 > /dev/null 2>&1 &
env:
OCTANE_SERVER: frankenphp
- name: "Install Playwright Browsers"
run: npx playwright install --with-deps
# Only the extension specs, the core specs run without extensions in playwright.yml
- name: "Run Playwright tests"
run: npx playwright test extensions/
env:
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
MAILPIT_BASE_URL: 'http://localhost:8025'
# No artifacts are uploaded on purpose. This repository is public, so every artifact is
# downloadable by anyone, and Playwright's reports carry the source of the specs that
# produced them (traces embed whole source files, error-context.md embeds a window of the
# spec). Those specs live in the private extension repositories. Failures are diagnosed from
# the job log; to inspect a trace, reproduce the failure locally with the extension checked out.

View File

@@ -1,22 +0,0 @@
<?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';
}

View File

@@ -1,16 +0,0 @@
<?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';
}

View File

@@ -1,32 +0,0 @@
<?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';
}

View File

@@ -1,24 +0,0 @@
<?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';
}

View File

@@ -0,0 +1,107 @@
<?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));
}
}

View File

@@ -1,174 +0,0 @@
<?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}'),
];
}
}

View File

@@ -1,22 +0,0 @@
<?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'),
];
}
}

View File

@@ -1,19 +0,0 @@
<?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 [
];
}
}

View File

@@ -1,22 +0,0 @@
<?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'),
];
}
}

View File

@@ -1,21 +0,0 @@
<?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');
}
}

View File

@@ -29,6 +29,8 @@ class ClientController extends Controller
/** /**
* Get clients * Get clients
* *
* @return ClientCollection<ClientResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getClients * @operationId getClients

View File

@@ -1,241 +0,0 @@
<?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);
}
}

View File

@@ -9,6 +9,7 @@ use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Http\Requests\V1\Invitation\InvitationIndexRequest; use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
use App\Http\Requests\V1\Invitation\InvitationStoreRequest; use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
use App\Http\Resources\V1\Invitation\InvitationCollection; use App\Http\Resources\V1\Invitation\InvitationCollection;
use App\Http\Resources\V1\Invitation\InvitationResource;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Service\InvitationService; use App\Service\InvitationService;
@@ -29,6 +30,8 @@ class InvitationController extends Controller
/** /**
* List all invitations of an organization * List all invitations of an organization
* *
* @return InvitationCollection<InvitationResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getInvitations * @operationId getInvitations
@@ -85,8 +88,6 @@ class InvitationController extends Controller
/** /**
* Remove a pending invitation * 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 * @throws AuthorizationException
* *
* @operationId removeInvitation * @operationId removeInvitation

View File

@@ -48,6 +48,8 @@ class MemberController extends Controller
/** /**
* List all members of an organization * List all members of an organization
* *
* @return MemberCollection<MemberResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getMembers * @operationId getMembers
@@ -143,9 +145,6 @@ class MemberController extends Controller
/** /**
* Merge one member into another * 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 AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember * @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws Throwable * @throws Throwable

View File

@@ -35,6 +35,8 @@ class ProjectController extends Controller
/** /**
* Get projects visible to the current user * Get projects visible to the current user
* *
* @return ProjectCollection<ProjectResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getProjects * @operationId getProjects

View File

@@ -36,6 +36,8 @@ class ProjectMemberController extends Controller
/** /**
* Get project members for project * Get project members for project
* *
* @return ProjectMemberCollection<ProjectMemberResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getProjectMembers * @operationId getProjectMembers

View File

@@ -10,6 +10,7 @@ use App\Http\Requests\V1\Report\ReportStoreRequest;
use App\Http\Requests\V1\Report\ReportUpdateRequest; use App\Http\Requests\V1\Report\ReportUpdateRequest;
use App\Http\Resources\V1\Report\DetailedReportResource; use App\Http\Resources\V1\Report\DetailedReportResource;
use App\Http\Resources\V1\Report\ReportCollection; use App\Http\Resources\V1\Report\ReportCollection;
use App\Http\Resources\V1\Report\ReportResource;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Report; use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto; use App\Service\Dto\ReportPropertiesDto;
@@ -34,6 +35,8 @@ class ReportController extends Controller
/** /**
* Get reports * Get reports
* *
* @return ReportCollection<ReportResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getReports * @operationId getReports
@@ -68,8 +71,6 @@ class ReportController extends Controller
/** /**
* Create report * 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 * @throws AuthorizationException
* *
* @operationId createReport * @operationId createReport

View File

@@ -29,6 +29,8 @@ class TagController extends Controller
/** /**
* Get tags * Get tags
* *
* @return TagCollection<TagResource>
*
* @operationId getTags * @operationId getTags
* *
* @throws AuthorizationException * @throws AuthorizationException

View File

@@ -51,6 +51,8 @@ class TaskController extends Controller
/** /**
* Get tasks * Get tasks
* *
* @return TaskCollection<TaskResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getTasks * @operationId getTasks

View File

@@ -109,13 +109,10 @@ class TimeEntryController extends Controller
/** /**
* Get time entries in organization * Get time entries in organization
* *
* 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. * If you only need time entries for a specific user, you can filter by `member_id`.
* 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. * Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
* *
* 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. * @return TimeEntryCollection<TimeEntryResource>
* 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 * @throws AuthorizationException
* *
@@ -354,11 +351,6 @@ class TimeEntryController extends Controller
* The parameters `group` and `sub_group` allow you to group the time entries by different criteria. * 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. * 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 * @operationId getAggregatedTimeEntries
* *
* @return array{ * @return array{
@@ -592,12 +584,6 @@ class TimeEntryController extends Controller
/** /**
* Create time entry * 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 AuthorizationException
* @throws TimeEntryStillRunningApiException * @throws TimeEntryStillRunningApiException
* *
@@ -648,8 +634,6 @@ class TimeEntryController extends Controller
/** /**
* Update time entry * 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 * @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
* *
* @operationId updateTimeEntry * @operationId updateTimeEntry
@@ -718,10 +702,6 @@ class TimeEntryController extends Controller
/** /**
* Update multiple time entries * 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 * @operationId updateMultipleTimeEntries
* *
* @throws AuthorizationException * @throws AuthorizationException

View File

@@ -12,7 +12,7 @@ class TimeZoneController extends Controller
/** /**
* Get all timezones * Get all timezones
* *
* @response array{key: string}[] * @response object{key: string}[]
* *
* @operationId getTimezones * @operationId getTimezones
*/ */

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web; namespace App\Http\Controllers\Web;
use App\Enums\Weekday;
use App\Service\Dto\UserAgentDto; use App\Service\Dto\UserAgentDto;
use App\Service\TimezoneService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -92,6 +94,8 @@ class UserProfileController extends Controller
$this->validateTwoFactorAuthenticationState($request); $this->validateTwoFactorAuthenticationState($request);
return Inertia::render('Profile/Show', [ return Inertia::render('Profile/Show', [
'timezones' => app(TimezoneService::class)->getSelectOptions(),
'weekdays' => Weekday::toSelectArray(),
'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'), 'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
'sessions' => $this->sessions($request), 'sessions' => $this->sessions($request),
]); ]);

View File

@@ -42,7 +42,6 @@ class HandleInertiaRequests extends Middleware
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing'); $hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing'); $hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
$hasServices = Module::has('Services') && Module::isEnabled('Services'); $hasServices = Module::has('Services') && Module::isEnabled('Services');
$hasGoals = Module::has('Goals') && Module::isEnabled('Goals');
/** @var BillingContract $billing */ /** @var BillingContract $billing */
$billing = app(BillingContract::class); $billing = app(BillingContract::class);
@@ -53,13 +52,11 @@ class HandleInertiaRequests extends Middleware
'has_billing_extension' => $hasBilling, 'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing, 'has_invoicing_extension' => $hasInvoicing,
'has_services_extension' => $hasServices, 'has_services_extension' => $hasServices,
'has_goals_extension' => $hasGoals,
'billing' => $currentOrganization !== null ? [ 'billing' => $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization), 'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization), 'has_trial' => $billing->hasTrial($currentOrganization),
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(), 'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
'is_blocked' => $billing->isBlocked($currentOrganization), 'is_blocked' => $billing->isBlocked($currentOrganization),
'goal_limit' => $billing->getGoalLimit($currentOrganization),
] : null, ] : null,
'flash' => [ 'flash' => [
'message' => fn () => $request->session()->get('message'), 'message' => fn () => $request->session()->get('message'),

View File

@@ -1,144 +0,0 @@
<?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;
}
}

View File

@@ -1,61 +0,0 @@
<?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');
}
}

View File

@@ -1,168 +0,0 @@
<?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'));
}
}

View File

@@ -1,130 +0,0 @@
<?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');
}
}

View File

@@ -27,7 +27,6 @@ class MemberUpdateRequest extends BaseFormRequest
'string', 'string',
Rule::enum(Role::class), Rule::enum(Role::class),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -36,7 +36,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
'string', 'string',
new CurrencyRule, new CurrencyRule,
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -55,7 +55,6 @@ class ProjectStoreRequest extends BaseFormRequest
'required', 'required',
'boolean', 'boolean',
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -68,7 +68,6 @@ class ProjectUpdateRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->uuid(),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge([ 'billable_rate' => array_merge([
'nullable', 'nullable',
], ],

View File

@@ -31,7 +31,6 @@ class ProjectMemberStoreRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->uuid(),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -21,7 +21,6 @@ class ProjectMemberUpdateRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -35,7 +35,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// 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 // Filter by member ID
'member_id' => [ 'member_id' => [
'string', 'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
@@ -155,7 +155,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string', 'string',
Rule::enum(TimeEntryType::class), Rule::enum(TimeEntryType::class),
], ],
// Limit the number of returned time entries (default: 100) // Limit the number of returned time entries (default: 150)
'limit' => [ 'limit' => [
'integer', 'integer',
'min:1', 'min:1',

View File

@@ -32,7 +32,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// 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) // ID of the organization member that the time entry should belong to
'member_id' => [ 'member_id' => [
'required', 'required',
'string', 'string',
@@ -86,7 +86,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
'after_or_equal:start', 'after_or_equal:start',
], ],
// Whether time entry is billable. Not derived from the project: set it to the project's is_billable value to match the web app // Whether time entry is billable
'billable' => [ 'billable' => [
'required', 'required',
'boolean', 'boolean',

View File

@@ -43,10 +43,9 @@ class UserUpdateRequest extends BaseFormRequest
'email' => [ 'email' => [
'email:rfc,strict', 'email:rfc,strict',
'max:255', 'max:255',
UniqueEloquent::make(User::class, 'email')->query(function (Builder $query) { UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */ /** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false) return $query->where('is_placeholder', '=', false);
->whereKeyNot($this->user->getKey());
}), }),
], ],
'photo' => [ 'photo' => [

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
interface PaginatedResourceCollection {}

View File

@@ -21,15 +21,15 @@ class ApiTokenResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of the API token, this ID is NOT a UUID */ /** @var string $id ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name of the API token */ /** @var string $name Name of the API token */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Whether the API token is revoked */ /** @var bool $revoked Whether the API token is revoked */
'revoked' => $this->resource->revoked, 'revoked' => $this->resource->revoked,
/** List of scopes that the API token has */ /** @var array<string> $scopes List of scopes that the API token has */
'scopes' => $this->resource->scopes, 'scopes' => $this->resource->scopes,
/** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ /** @var string $created_at 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), '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) */ /** @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), 'expires_at' => $this->formatDateTime($this->resource->expires_at),

View File

@@ -29,15 +29,15 @@ class ApiTokenWithAccessTokenResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of the API token, this ID is NOT a UUID */ /** @var string $id ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** @var string $name Name of the API token */ /** @var string $name Name of the API token */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Whether the API token is revoked */ /** @var bool $revoked Whether the API token is revoked */
'revoked' => $this->resource->revoked, 'revoked' => $this->resource->revoked,
/** List of scopes that the API token has */ /** @var array<string> $scopes List of scopes that the API token has */
'scopes' => $this->resource->scopes, 'scopes' => $this->resource->scopes,
/** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ /** @var string $created_at 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), '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) */ /** @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), 'expires_at' => $this->formatDateTime($this->resource->expires_at),

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Client; namespace App\Http\Resources\V1\Client;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class ClientCollection extends ResourceCollection class ClientCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -21,15 +21,15 @@ class ClientResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID */ /** @var string $id ID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Whether the client is archived */ /** @var bool $is_archived Whether the client is archived */
'is_archived' => $this->resource->is_archived, 'is_archived' => $this->resource->is_archived,
/** When the tag was created */ /** @var string $created_at When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** When the tag was last updated */ /** @var string $updated_at When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -1,45 +0,0 @@
<?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();
}
}

View File

@@ -1,90 +0,0 @@
<?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),
];
}
}

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Invitation; namespace App\Http\Resources\V1\Invitation;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class InvitationCollection extends ResourceCollection class InvitationCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -21,11 +21,11 @@ class InvitationResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of the invitation */ /** @var string $id ID of the invitation */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Email */ /** @var string $email Email */
'email' => $this->resource->email, 'email' => $this->resource->email,
/** Role */ /** @var string $role Role */
'role' => $this->resource->role, 'role' => $this->resource->role,
]; ];
} }

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Member; namespace App\Http\Resources\V1\Member;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class MemberCollection extends ResourceCollection class MemberCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -22,19 +22,19 @@ class MemberResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of membership */ /** @var string $id ID of membership */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** ID of user */ /** @var string $id ID of user */
'user_id' => $this->resource->user->id, 'user_id' => $this->resource->user->id,
/** Name */ /** @var string $name Name */
'name' => $this->resource->user->name, 'name' => $this->resource->user->name,
/** Email */ /** @var string $email Email */
'email' => $this->resource->user->email, 'email' => $this->resource->user->email,
/** Role */ /** @var string $role Role */
'role' => $this->resource->role, 'role' => $this->resource->role,
/** Placeholder user for imports, user might not really exist and does not know about this placeholder membership */ /** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
'is_placeholder' => $this->resource->user->is_placeholder, 'is_placeholder' => $this->resource->user->is_placeholder,
/** Billable rate in cents per hour */ /** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate, 'billable_rate' => $this->resource->billable_rate,
]; ];
} }

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Member; namespace App\Http\Resources\V1\Member;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class PersonalMembershipCollection extends ResourceCollection class PersonalMembershipCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -21,17 +21,17 @@ class PersonalMembershipResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of membership */ /** @var string $id ID of membership */
'id' => $this->resource->id, 'id' => $this->resource->id,
'organization' => [ 'organization' => [
/** ID of organization */ /** @var string $id ID of organization */
'id' => $this->resource->organization->id, 'id' => $this->resource->organization->id,
/** Name of organization */ /** @var string $name Name of organization */
'name' => $this->resource->organization->name, 'name' => $this->resource->organization->name,
/** Currency code (ISO 4217) of organization */ /** @var string $currency Currency code (ISO 4217) of organization */
'currency' => $this->resource->organization->currency, 'currency' => $this->resource->organization->currency,
], ],
/** Role */ /** @var string $role Role */
'role' => $this->resource->role, 'role' => $this->resource->role,
]; ];
} }

View File

@@ -43,23 +43,23 @@ class OrganizationResource extends BaseResource
$currencyService = app(CurrencyService::class); $currencyService = app(CurrencyService::class);
return [ return [
/** ID */ /** @var string $id ID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Personal organizations automatically created after registration */ /** @var bool $color Personal organizations automatically created after registration */
'is_personal' => $this->resource->personal_team, 'is_personal' => $this->resource->personal_team,
/** Billable rate in cents per hour */ /** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null, 'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** Can members of the organization with role "employee" see the billable rates */ /** @var bool $employees_can_see_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, 'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */ /** @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 */
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks, 'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
/** Prevent creating overlapping time entries (only new entries) */ /** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries, 'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** Whether members of the organization can track breaks */ /** @var bool $breaks_enabled Whether members of the organization can track breaks */
'breaks_enabled' => $this->resource->breaks_enabled, 'breaks_enabled' => $this->resource->breaks_enabled,
/** Currency code (ISO 4217) */ /** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency, 'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */ /** @var string $currency_symbol Currency symbol */
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency), 'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),

View File

@@ -4,11 +4,12 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Project; namespace App\Http\Resources\V1\Project;
use App\Http\Resources\PaginatedResourceCollection;
use App\Models\Project; use App\Models\Project;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class ProjectCollection extends ResourceCollection class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
private bool $showBillableRates; private bool $showBillableRates;

View File

@@ -30,25 +30,25 @@ class ProjectResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of project */ /** @var string $id ID of project */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name of project */ /** @var string $name Name of project */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Color of project */ /** @var string $color Color of project */
'color' => $this->resource->color, 'color' => $this->resource->color,
/** @var string|null $client_id ID of client */ /** @var string|null $client_id ID of client */
'client_id' => $this->resource->client_id, 'client_id' => $this->resource->client_id,
/** Whether the client is archived */ /** @var bool $is_archived Whether the client is archived */
'is_archived' => $this->resource->is_archived, 'is_archived' => $this->resource->is_archived,
/** Billable rate in cents per hour */ /** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null, 'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** Project time entries billable default */ /** @var bool $is_billable Project time entries billable default */
'is_billable' => $this->resource->is_billable, 'is_billable' => $this->resource->is_billable,
/** Estimated time in seconds */ /** @var int|null $estimated_time Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time, 'estimated_time' => $this->resource->estimated_time,
/** Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */ /** @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' => $this->resource->spent_time, 'spent_time' => $this->resource->spent_time,
/** Whether the project is public */ /** @var bool $is_public Whether the project is public */
'is_public' => $this->resource->is_public, 'is_public' => $this->resource->is_public,
]; ];
} }

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\ProjectMember; namespace App\Http\Resources\V1\ProjectMember;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class ProjectMemberCollection extends ResourceCollection class ProjectMemberCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -21,13 +21,13 @@ class ProjectMemberResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of project member */ /** @var string $id ID of project member */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Billable rate in cents per hour */ /** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate, 'billable_rate' => $this->resource->billable_rate,
/** ID of the organization member */ /** @var string $member_id ID of the organization member */
'member_id' => $this->resource->member_id, 'member_id' => $this->resource->member_id,
/** ID of the project */ /** @var string $project_id ID of the project */
'project_id' => $this->resource->project_id, 'project_id' => $this->resource->project_id,
]; ];
} }

View File

@@ -21,36 +21,36 @@ class DetailedReportResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of the report */ /** @var string $id ID of the report */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Description */ /** @var string|null $email Description */
'description' => $this->resource->description, 'description' => $this->resource->description,
/** Whether the report can be accessed via an external link */ /** @var bool $is_public Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public, 'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */ /** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until), '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 */ /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(), 'shareable_link' => $this->resource->getShareableLink(),
'properties' => [ 'properties' => [
/** Type of first grouping */ /** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value, 'group' => $this->resource->properties->group->value,
/** Type of second grouping */ /** @var string $sub_group Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value, 'sub_group' => $this->resource->properties->subGroup->value,
/** Type of grouping of the historic aggregation (time chart) */ /** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value, 'history_group' => $this->resource->properties->historyGroup->value,
/** Start date of the report */ /** @var string $start Start date of the report */
'start' => $this->formatDateTime($this->resource->properties->start), 'start' => $this->formatDateTime($this->resource->properties->start),
/** End date of the report */ /** @var string $end End date of the report */
'end' => $this->formatDateTime($this->resource->properties->end), 'end' => $this->formatDateTime($this->resource->properties->end),
/** Whether the report is active */ /** @var bool|null $active Whether the report is active */
'active' => $this->resource->properties->active, 'active' => $this->resource->properties->active,
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */ /** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
'member_ids' => $this->resource->properties->memberIds?->toArray(), 'member_ids' => $this->resource->properties->memberIds?->toArray(),
/** Filter by billable status */ /** @var bool|null $billable Filter by billable status */
'billable' => $this->resource->properties->billable, 'billable' => $this->resource->properties->billable,
/** Filter by time entry type */ /** @var string|null $time_entry_type Filter by time entry type */
'time_entry_type' => $this->resource->properties->timeEntryType?->value, 'time_entry_type' => $this->resource->properties->timeEntryType?->value,
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */ /** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
'client_ids' => $this->resource->properties->clientIds?->toArray(), 'client_ids' => $this->resource->properties->clientIds?->toArray(),
@@ -58,18 +58,18 @@ class DetailedReportResource extends BaseResource
'project_ids' => $this->resource->properties->projectIds?->toArray(), 'project_ids' => $this->resource->properties->projectIds?->toArray(),
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */ /** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->properties->tagIds?->toArray(), 'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** Tag match type */ /** @var string|null $tag_match_type Tag match type */
'tag_match_type' => $this->resource->properties->tagMatchType?->value, 'tag_match_type' => $this->resource->properties->tagMatchType?->value,
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */ /** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(), 'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** Rounding type for time entries */ /** @var string|null $rounding_type Rounding type for time entries */
'rounding_type' => $this->resource->properties->roundingType?->value, 'rounding_type' => $this->resource->properties->roundingType?->value,
/** Rounding minutes for time entries */ /** @var int|null $rounding_minutes Rounding minutes for time entries */
'rounding_minutes' => $this->resource->properties->roundingMinutes, 'rounding_minutes' => $this->resource->properties->roundingMinutes,
], ],
/** Date when the report was created */ /** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** Date when the report was last updated */ /** @var string $updated_at Date when the report was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -73,13 +73,13 @@ class DetailedWithDataReportResource extends BaseResource
$currencyService = app(CurrencyService::class); $currencyService = app(CurrencyService::class);
return [ return [
/** Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Description */ /** @var string|null $email Description */
'description' => $this->resource->description, 'description' => $this->resource->description,
/** @var string|null $public_until Date until the report is public */ /** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until), 'public_until' => $this->formatDateTime($this->resource->public_until),
/** Currency code (ISO 4217) */ /** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->organization->currency, 'currency' => $this->resource->organization->currency,
/** @var NumberFormat $number_format Number format */ /** @var NumberFormat $number_format Number format */
'number_format' => $this->resource->organization->number_format->value, 'number_format' => $this->resource->organization->number_format->value,
@@ -94,15 +94,15 @@ class DetailedWithDataReportResource extends BaseResource
/** @var TimeFormat $time_format Time format */ /** @var TimeFormat $time_format Time format */
'time_format' => $this->resource->organization->time_format->value, 'time_format' => $this->resource->organization->time_format->value,
'properties' => [ 'properties' => [
/** Type of first grouping */ /** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value, 'group' => $this->resource->properties->group->value,
/** Type of second grouping */ /** @var string $sub_group Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value, 'sub_group' => $this->resource->properties->subGroup->value,
/** Type of grouping of the historic aggregation (time chart) */ /** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value, 'history_group' => $this->resource->properties->historyGroup->value,
/** Start date of the report */ /** @var string $start Start date of the report */
'start' => $this->formatDateTime($this->resource->properties->start), 'start' => $this->formatDateTime($this->resource->properties->start),
/** End date of the report */ /** @var string $end End date of the report */
'end' => $this->formatDateTime($this->resource->properties->end), 'end' => $this->formatDateTime($this->resource->properties->end),
], ],
/** @var array{ /** @var array{

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Report; namespace App\Http\Resources\V1\Report;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class ReportCollection extends ResourceCollection class ReportCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -21,21 +21,21 @@ class ReportResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of the report */ /** @var string $id ID of the report */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Description */ /** @var string|null $email Description */
'description' => $this->resource->description, 'description' => $this->resource->description,
/** Whether the report can be accessed via an external link */ /** @var bool $is_public Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public, 'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */ /** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until), '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 */ /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(), 'shareable_link' => $this->resource->getShareableLink(),
/** Date when the report was created */ /** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** Date when the report was last updated */ /** @var string $updated_at Date when the report was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Tag; namespace App\Http\Resources\V1\Tag;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class TagCollection extends ResourceCollection class TagCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -21,13 +21,13 @@ class TagResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID */ /** @var string $id ID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** When the tag was created */ /** @var string $created_at When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** When the tag was last updated */ /** @var string $updated_at When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Task; namespace App\Http\Resources\V1\Task;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class TaskCollection extends ResourceCollection class TaskCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -22,21 +22,21 @@ class TaskResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID */ /** @var string $id ID */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name */ /** @var string $name Name */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Whether the task is done */ /** @var bool $is_done Whether the task is done */
'is_done' => $this->resource->is_done, 'is_done' => $this->resource->is_done,
/** ID of the project */ /** @var string $project_id ID of the project */
'project_id' => $this->resource->project_id, 'project_id' => $this->resource->project_id,
/** Estimated time in seconds */ /** @var int|null $estimated_time Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time, 'estimated_time' => $this->resource->estimated_time,
/** Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */ /** @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' => $this->resource->spent_time, 'spent_time' => $this->resource->spent_time,
/** When the tag was created */ /** @var string $created_at When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** When the tag was last updated */ /** @var string $updated_at When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at), 'updated_at' => $this->formatDateTime($this->resource->updated_at),
]; ];
} }

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\TimeEntry; namespace App\Http\Resources\V1\TimeEntry;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class TimeEntryCollection extends ResourceCollection class TimeEntryCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -21,10 +21,10 @@ class TimeEntryResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of time entry */ /** @var string $id ID of time entry */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** /**
* Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) * @var string $start Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)
*/ */
'start' => $this->formatDateTime($this->resource->start), 'start' => $this->formatDateTime($this->resource->start),
/** /**
@@ -35,19 +35,19 @@ class TimeEntryResource extends BaseResource
'duration' => (int) $this->resource->getDuration()?->totalSeconds, 'duration' => (int) $this->resource->getDuration()?->totalSeconds,
/** @var string|null $description Description of time entry */ /** @var string|null $description Description of time entry */
'description' => $this->resource->description, 'description' => $this->resource->description,
/** ID of task */ /** @var string|null $task_id ID of task */
'task_id' => $this->resource->task_id, 'task_id' => $this->resource->task_id,
/** ID of project */ /** @var string|null $project_id ID of project */
'project_id' => $this->resource->project_id, 'project_id' => $this->resource->project_id,
/** ID of organization */ /** @var string $organization_id ID of organization */
'organization_id' => $this->resource->organization_id, 'organization_id' => $this->resource->organization_id,
/** ID of user */ /** @var string $user_id ID of user */
'user_id' => $this->resource->user_id, 'user_id' => $this->resource->user_id,
/** List of tag IDs */ /** @var array<string> $tags List of tag IDs */
'tags' => $this->resource->tags ?? [], 'tags' => $this->resource->tags ?? [],
/** Whether time entry is billable */ /** @var bool $billable Whether time entry is billable */
'billable' => $this->resource->billable, 'billable' => $this->resource->billable,
/** Type of the time entry (`work` time or a `break`) */ /** @var string $type Type of the time entry (`work` time or a `break`) */
'type' => $this->resource->type->value, 'type' => $this->resource->type->value,
]; ];
} }

View File

@@ -22,21 +22,21 @@ class UserResource extends BaseResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
/** ID of user */ /** @var string $id ID of user */
'id' => $this->resource->id, 'id' => $this->resource->id,
/** Name of user */ /** @var string $name Name of user */
'name' => $this->resource->name, 'name' => $this->resource->name,
/** Email of user */ /** @var string $email Email of user */
'email' => $this->resource->email, 'email' => $this->resource->email,
/** Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */ /** @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) */
'pending_email' => $this->resource->pending_email, 'pending_email' => $this->resource->pending_email,
/** Profile photo URL */ /** @var string $profile_photo_url Profile photo URL */
'profile_photo_url' => $this->resource->profile_photo_url, 'profile_photo_url' => $this->resource->profile_photo_url,
/** Timezone (f.e. Europe/Berlin or America/New_York) */ /** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */
'timezone' => $this->resource->timezone, 'timezone' => $this->resource->timezone,
/** @var Weekday $week_start Starting day of the week */ /** @var Weekday $week_start Starting day of the week */
'week_start' => $this->resource->week_start->value, 'week_start' => $this->resource->week_start->value,
/** Whether to email the user when a time entry has been running for more than 8 hours */ /** @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 */
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email, 'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
]; ];
} }

View File

@@ -4,19 +4,15 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\AuditableWithoutOwner;
use Database\Factories\AuditFactory; use Database\Factories\AuditFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Models\Audit as PackageAuditModel; use OwenIt\Auditing\Models\Audit as PackageAuditModel;
/** /**
* @property int $id * @property int $id
* @property string|null $actor_type * @property string|null $user_type
* @property string|null $actor_id * @property string|null $user_id
* @property string $event * @property string $event
* @property string $auditable_type * @property string $auditable_type
* @property string $auditable_id * @property string $auditable_id
@@ -26,71 +22,13 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
* @property string|null $ip_address * @property string|null $ip_address
* @property string|null $user_agent * @property string|null $user_agent
* @property string|null $tags * @property string|null $tags
* @property string|null $owner_user_id
* @property string|null $owner_organization_id
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read User|null $ownerUser
* @property-read Organization|null $ownerOrganization
* *
* @method static AuditFactory factory() * @method static AuditFactory factory()
* @method static Builder<Audit> whereMissingOwner()
*/ */
class Audit extends PackageAuditModel class Audit extends PackageAuditModel
{ {
/** @use HasFactory<AuditFactory> */ /** @use HasFactory<AuditFactory> */
use HasFactory; 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());
}
} }

View File

@@ -1,22 +0,0 @@
<?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;
}

View File

@@ -1,14 +0,0 @@
<?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 {}

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Models\Concerns; namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable; use OwenIt\Auditing\Auditable;
trait CustomAuditable trait CustomAuditable
@@ -21,92 +19,4 @@ trait CustomAuditable
{ {
$this->auditEvents = []; $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;
}
} }

View File

@@ -1,114 +0,0 @@
<?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');
}
}

View File

@@ -28,7 +28,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property-read User $user * @property-read User $user
* @property-read Collection<int, ProjectMember> $projectMembers * @property-read Collection<int, ProjectMember> $projectMembers
* @property-read Collection<int, TimeEntry> $timeEntries * @property-read Collection<int, TimeEntry> $timeEntries
* @property-read Collection<int, Goal> $goals
* *
* @method static MemberFactory factory() * @method static MemberFactory factory()
*/ */
@@ -79,14 +78,4 @@ class Member extends Pivot implements AuditableContract
{ {
return $this->hasMany(ProjectMember::class, 'member_id'); 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');
}
} }

View File

@@ -96,16 +96,6 @@ class Organization extends Model implements AuditableContract
protected $attributes = [ protected $attributes = [
]; ];
public function getAuditOwnerOrganizationId(): ?string
{
return $this->getKey();
}
protected function isAuditOwnerOfItself(): bool
{
return true;
}
/** /**
* Get all the users that belong to the team. * Get all the users that belong to the team.
* *

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\AuditableThroughParent;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\ProjectMemberFactory; use Database\Factories\ProjectMemberFactory;
@@ -30,7 +29,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization) * @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
* @method static ProjectMemberFactory factory() * @method static ProjectMemberFactory factory()
*/ */
class ProjectMember extends Model implements AuditableContract, AuditableThroughParent class ProjectMember extends Model implements AuditableContract
{ {
use CustomAuditable; use CustomAuditable;
@@ -83,12 +82,4 @@ class ProjectMember extends Model implements AuditableContract, AuditableThrough
$query->whereBelongsTo($organization, 'organization'); $query->whereBelongsTo($organization, 'organization');
}); });
} }
/**
* @return BelongsTo<Project, $this>
*/
public function getAuditParentRelation(): BelongsTo
{
return $this->project();
}
} }

View File

@@ -124,16 +124,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
'send_time_entry_still_running_email' => true, '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. * Get the URL to the user's profile photo.
* *

View File

@@ -17,7 +17,6 @@ use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use App\Service\BillingContract; use App\Service\BillingContract;
use App\Service\GoalsContract;
use App\Service\IpLookup\IpLookupServiceContract; use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\IpLookup\NoIpLookupService; use App\Service\IpLookup\NoIpLookupService;
use App\Service\PermissionStore; use App\Service\PermissionStore;
@@ -104,7 +103,6 @@ class AppServiceProvider extends ServiceProvider
// Extensions // Extensions
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class); $this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
$this->app->bind(BillingContract::class); $this->app->bind(BillingContract::class);
$this->app->bind(GoalsContract::class);
// Storage // Storage
// The local driver ignores the ResponseContentDisposition option of temporaryUrl, // The local driver ignores the ResponseContentDisposition option of temporaryUrl,

View File

@@ -43,15 +43,6 @@ class BillingContract
return null; 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 * Check if the organization is blocked
* A blocked organization is an organization that has more than 1 non-placeholder member but no subscription/trial * A blocked organization is an organization that has more than 1 non-placeholder member but no subscription/trial

View File

@@ -8,11 +8,9 @@ use App\Enums\Role;
use App\Events\BeforeOrganizationDeletion; use App\Events\BeforeOrganizationDeletion;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Models\Client; use App\Models\Client;
use App\Models\Goal;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Models\Passport\RefreshToken;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\Report; use App\Models\Report;
@@ -77,9 +75,6 @@ class DeletionService
// Delete all reports // Delete all reports
Report::query()->whereBelongsTo($organization, 'organization')->delete(); Report::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all goals
Goal::query()->whereBelongsTo($organization, 'organization')->delete();
// Reset the current organization // Reset the current organization
$organization->owner() $organization->owner()
->where('current_team_id', $organization->getKey()) ->where('current_team_id', $organization->getKey())
@@ -174,10 +169,6 @@ class DeletionService
} }
} }
// Refresh tokens are not linked to the user directly, so they need to be deleted via their access tokens.
// Otherwise a still-valid refresh token could be used to issue a new access token for a deleted user,
// which fails with a foreign key violation on oauth_access_tokens.user_id.
RefreshToken::query()->whereIn('access_token_id', $user->accessTokens()->pluck('id'))->delete();
$user->accessTokens()->delete(); $user->accessTokens()->delete();
$user->authCodes()->delete(); $user->authCodes()->delete();

View File

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

View File

@@ -1,22 +0,0 @@
<?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,
) {}
}

View File

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

View File

@@ -1,122 +0,0 @@
<?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,
};
}
}

View File

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

View File

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

View File

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

View File

@@ -10,9 +10,7 @@ use App\Models\Member;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class TimeEntryFilter class TimeEntryFilter
{ {
@@ -31,26 +29,6 @@ class TimeEntryFilter
$this->builder = $builder; $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 public function addEndFilter(?string $dateTime): self
{ {
if ($dateTime === null) { if ($dateTime === null) {

79
composer.lock generated
View File

@@ -429,24 +429,23 @@
}, },
{ {
"name": "brick/math", "name": "brick/math",
"version": "1.0.0", "version": "0.19.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/brick/math.git", "url": "https://github.com/brick/math.git",
"reference": "2effe05d2177c451b86c6a073196a4034c02f211" "reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211", "url": "https://api.github.com/repos/brick/math/zipball/a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce",
"reference": "2effe05d2177c451b86c6a073196a4034c02f211", "reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^8.2" "php": "^8.2"
}, },
"require-dev": { "require-dev": {
"phpstan/phpstan": "2.2.13", "phpstan/phpstan": "2.1.22",
"phpstan/phpstan-phpunit": "2.0.18",
"phpunit/phpunit": "^11.5" "phpunit/phpunit": "^11.5"
}, },
"type": "library", "type": "library",
@@ -477,7 +476,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/brick/math/issues", "issues": "https://github.com/brick/math/issues",
"source": "https://github.com/brick/math/tree/1.0.0" "source": "https://github.com/brick/math/tree/0.19.1"
}, },
"funding": [ "funding": [
{ {
@@ -485,7 +484,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-09-12T10:28:18+00:00" "time": "2026-08-08T23:03:16+00:00"
}, },
{ {
"name": "brick/money", "name": "brick/money",
@@ -4219,16 +4218,16 @@
}, },
{ {
"name": "laravel/fortify", "name": "laravel/fortify",
"version": "v1.40.0", "version": "v1.39.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/fortify.git", "url": "https://github.com/laravel/fortify.git",
"reference": "fe0fce8814660317df0684f2c7be3b573def67d3" "reference": "b1fc50707bbe007fd92165d8b7d460ab549b355a"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/fortify/zipball/fe0fce8814660317df0684f2c7be3b573def67d3", "url": "https://api.github.com/repos/laravel/fortify/zipball/b1fc50707bbe007fd92165d8b7d460ab549b355a",
"reference": "fe0fce8814660317df0684f2c7be3b573def67d3", "reference": "b1fc50707bbe007fd92165d8b7d460ab549b355a",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4279,24 +4278,24 @@
"issues": "https://github.com/laravel/fortify/issues", "issues": "https://github.com/laravel/fortify/issues",
"source": "https://github.com/laravel/fortify" "source": "https://github.com/laravel/fortify"
}, },
"time": "2026-09-10T11:52:08+00:00" "time": "2026-08-23T07:46:41+00:00"
}, },
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v13.33.0", "version": "v13.32.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/framework.git", "url": "https://github.com/laravel/framework.git",
"reference": "91188a17ceaa3dbace6e8a5f7abd0d042e466359" "reference": "cdd8b33c246719acdd118c705ce8c7ab5ef48a96"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/91188a17ceaa3dbace6e8a5f7abd0d042e466359", "url": "https://api.github.com/repos/laravel/framework/zipball/cdd8b33c246719acdd118c705ce8c7ab5ef48a96",
"reference": "91188a17ceaa3dbace6e8a5f7abd0d042e466359", "reference": "cdd8b33c246719acdd118c705ce8c7ab5ef48a96",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19",
"composer-runtime-api": "^2.2", "composer-runtime-api": "^2.2",
"doctrine/inflector": "^2.0.5", "doctrine/inflector": "^2.0.5",
"dragonmantank/cron-expression": "^3.4", "dragonmantank/cron-expression": "^3.4",
@@ -4510,20 +4509,20 @@
"issues": "https://github.com/laravel/framework/issues", "issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework" "source": "https://github.com/laravel/framework"
}, },
"time": "2026-09-22T14:12:33+00:00" "time": "2026-09-15T14:55:30+00:00"
}, },
{ {
"name": "laravel/octane", "name": "laravel/octane",
"version": "v2.20.0", "version": "v2.19.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/octane.git", "url": "https://github.com/laravel/octane.git",
"reference": "df4c38dcddc5f51e36e7f1d788841130c4fb8f45" "reference": "68a2516a0318baba0de0e4648f61e335c5e69dca"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/octane/zipball/df4c38dcddc5f51e36e7f1d788841130c4fb8f45", "url": "https://api.github.com/repos/laravel/octane/zipball/68a2516a0318baba0de0e4648f61e335c5e69dca",
"reference": "df4c38dcddc5f51e36e7f1d788841130c4fb8f45", "reference": "68a2516a0318baba0de0e4648f61e335c5e69dca",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4599,7 +4598,7 @@
"issues": "https://github.com/laravel/octane/issues", "issues": "https://github.com/laravel/octane/issues",
"source": "https://github.com/laravel/octane" "source": "https://github.com/laravel/octane"
}, },
"time": "2026-08-23T17:25:20+00:00" "time": "2026-08-13T13:58:52+00:00"
}, },
{ {
"name": "laravel/passkeys", "name": "laravel/passkeys",
@@ -4805,16 +4804,16 @@
}, },
{ {
"name": "laravel/serializable-closure", "name": "laravel/serializable-closure",
"version": "v2.1.0", "version": "v2.0.16",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/serializable-closure.git", "url": "https://github.com/laravel/serializable-closure.git",
"reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed",
"reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4862,7 +4861,7 @@
"issues": "https://github.com/laravel/serializable-closure/issues", "issues": "https://github.com/laravel/serializable-closure/issues",
"source": "https://github.com/laravel/serializable-closure" "source": "https://github.com/laravel/serializable-closure"
}, },
"time": "2026-09-22T14:32:34+00:00" "time": "2026-08-18T20:28:54+00:00"
}, },
{ {
"name": "laravel/tinker", "name": "laravel/tinker",
@@ -15338,16 +15337,16 @@
}, },
{ {
"name": "laravel/sail", "name": "laravel/sail",
"version": "v1.68.0", "version": "v1.67.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/sail.git", "url": "https://github.com/laravel/sail.git",
"reference": "2bc304083d515065b03944e425e62cb3c526c33e" "reference": "639e03ac12cf23def171770bcab05758045b2642"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/sail/zipball/2bc304083d515065b03944e425e62cb3c526c33e", "url": "https://api.github.com/repos/laravel/sail/zipball/639e03ac12cf23def171770bcab05758045b2642",
"reference": "2bc304083d515065b03944e425e62cb3c526c33e", "reference": "639e03ac12cf23def171770bcab05758045b2642",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -15397,7 +15396,7 @@
"issues": "https://github.com/laravel/sail/issues", "issues": "https://github.com/laravel/sail/issues",
"source": "https://github.com/laravel/sail" "source": "https://github.com/laravel/sail"
}, },
"time": "2026-09-18T14:34:46+00:00" "time": "2026-08-12T13:55:56+00:00"
}, },
{ {
"name": "laravel/sentinel", "name": "laravel/sentinel",
@@ -15457,16 +15456,16 @@
}, },
{ {
"name": "laravel/telescope", "name": "laravel/telescope",
"version": "v5.25.0", "version": "v5.24.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/telescope.git", "url": "https://github.com/laravel/telescope.git",
"reference": "65fe6724f8688de01dfa74b28a0edc191bc3caa8" "reference": "50cbcf4553ddfd8d4bd456b04f97dfed923007d9"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/telescope/zipball/65fe6724f8688de01dfa74b28a0edc191bc3caa8", "url": "https://api.github.com/repos/laravel/telescope/zipball/50cbcf4553ddfd8d4bd456b04f97dfed923007d9",
"reference": "65fe6724f8688de01dfa74b28a0edc191bc3caa8", "reference": "50cbcf4553ddfd8d4bd456b04f97dfed923007d9",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -15519,9 +15518,9 @@
"monitoring" "monitoring"
], ],
"support": { "support": {
"source": "https://github.com/laravel/telescope/tree/v5.25.0" "source": "https://github.com/laravel/telescope/tree/v5.24.0"
}, },
"time": "2026-09-09T14:16:19+00:00" "time": "2026-09-08T16:15:16+00:00"
}, },
{ {
"name": "mockery/mockery", "name": "mockery/mockery",

View File

@@ -32,7 +32,7 @@ return [
*/ */
'user' => [ 'user' => [
'morph_prefix' => 'actor', 'morph_prefix' => 'user',
'guards' => [ 'guards' => [
'web', 'web',
'api', 'api',

View File

@@ -35,7 +35,7 @@ return [
'sqlite' => [ 'sqlite' => [
'driver' => 'sqlite', 'driver' => 'sqlite',
'url' => env('DB_URL', env('DATABASE_URL')), 'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')), 'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '', 'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
@@ -47,7 +47,7 @@ return [
'pgsql' => [ 'pgsql' => [
'driver' => 'pgsql', 'driver' => 'pgsql',
'url' => env('DB_URL', env('DATABASE_URL')), 'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'), 'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'), 'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'forge'), 'database' => env('DB_DATABASE', 'forge'),
@@ -57,12 +57,12 @@ return [
'prefix' => '', 'prefix' => '',
'prefix_indexes' => true, 'prefix_indexes' => true,
'search_path' => 'public', 'search_path' => 'public',
'sslmode' => env('DB_SSL_MODE', 'prefer'), 'sslmode' => env('DB_SSLMODE', 'prefer'),
], ],
'pgsql_test' => [ 'pgsql_test' => [
'driver' => 'pgsql', 'driver' => 'pgsql',
'url' => env('DB_URL', env('DATABASE_URL')), 'url' => env('DB_URL'),
'host' => env('DB_TEST_HOST', '127.0.0.1'), 'host' => env('DB_TEST_HOST', '127.0.0.1'),
'port' => env('DB_TEST_PORT', '5432'), 'port' => env('DB_TEST_PORT', '5432'),
'database' => env('DB_TEST_DATABASE', 'forge'), 'database' => env('DB_TEST_DATABASE', 'forge'),
@@ -72,12 +72,12 @@ return [
'prefix' => '', 'prefix' => '',
'prefix_indexes' => true, 'prefix_indexes' => true,
'search_path' => 'public', 'search_path' => 'public',
'sslmode' => env('DB_SSL_MODE', 'prefer'), 'sslmode' => env('DB_SSLMODE', 'prefer'),
], ],
'sqlsrv' => [ 'sqlsrv' => [
'driver' => 'sqlsrv', 'driver' => 'sqlsrv',
'url' => env('DB_URL', env('DATABASE_URL')), 'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'), 'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'), 'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'), 'database' => env('DB_DATABASE', 'laravel'),

View File

@@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Extensions\Scramble\ApiExceptionTypeToSchema; use App\Extensions\Scramble\ApiExceptionTypeToSchema;
use App\Extensions\Scramble\PaginatedResourceCollectionTypeToSchema;
use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess; use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess;
return [ return [
@@ -27,30 +28,7 @@ return [
/* /*
* Description rendered on the home page of the API documentation (`/docs/api`). * Description rendered on the home page of the API documentation (`/docs/api`).
*/ */
'description' => <<<'MD' 'description' => '',
## 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,
], ],
/* /*
@@ -100,5 +78,6 @@ MD,
'extensions' => [ 'extensions' => [
ApiExceptionTypeToSchema::class, ApiExceptionTypeToSchema::class,
PaginatedResourceCollectionTypeToSchema::class,
], ],
]; ];

View File

@@ -1,161 +0,0 @@
<?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'),
]);
}
}

View File

@@ -91,7 +91,7 @@ class UserFactory extends Factory
public function withProfilePicture(): static public function withProfilePicture(): static
{ {
$profilePhoto = $this->generateProfilePhoto(); $profilePhoto = $this->faker->image(null, 500, 500);
/** @see FileHelpers::hashName */ /** @see FileHelpers::hashName */
$path = 'profile-photos/'.Str::random(40).'.png'; $path = 'profile-photos/'.Str::random(40).'.png';
Storage::disk(config('filesystems.public'))->put($path, $profilePhoto); Storage::disk(config('filesystems.public'))->put($path, $profilePhoto);
@@ -103,21 +103,6 @@ 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. * Indicate that the user should have a personal team.
*/ */

View File

@@ -18,8 +18,7 @@ class CreateAuditsTable extends Migration
Schema::connection($connection)->create($table, function (Blueprint $table): void { Schema::connection($connection)->create($table, function (Blueprint $table): void {
// Note: The morph prefix is hardcoded, since the columns are renamed in a later migration $morphPrefix = config('audit.user.morph_prefix', 'user');
$morphPrefix = 'user';
$table->bigIncrements('id'); $table->bigIncrements('id');
$table->string($morphPrefix.'_type')->nullable(); $table->string($morphPrefix.'_type')->nullable();

View File

@@ -1,56 +0,0 @@
<?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');
}
};

View File

@@ -1,42 +0,0 @@
<?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');
});
}
};

View File

@@ -1,94 +0,0 @@
<?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' : '';
}
};

View File

@@ -4,14 +4,11 @@ declare(strict_types=1);
namespace Database\Seeders; namespace Database\Seeders;
use App\Enums\GoalPeriod;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Events\DatabaseSeederAfterSeed; use App\Events\DatabaseSeederAfterSeed;
use App\Events\DatabaseSeederBeforeDelete; use App\Events\DatabaseSeederBeforeDelete;
use App\Models\Audit; use App\Models\Audit;
use App\Models\Client; use App\Models\Client;
use App\Models\Goal;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
@@ -22,7 +19,6 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use App\Service\Dto\GoalFiltersDto;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Laravel\Passport\AuthCode; use Laravel\Passport\AuthCode;
@@ -118,28 +114,13 @@ class DatabaseSeeder extends Seeder
Tag::factory()->forOrganization($organizationAcme)->create([ Tag::factory()->forOrganization($organizationAcme)->create([
'name' => 'Code Review', 'name' => 'Code Review',
]); ]);
$tagMeeting = Tag::factory()->forOrganization($organizationAcme)->create([ Tag::factory()->forOrganization($organizationAcme)->create([
'name' => 'Meeting', 'name' => 'Meeting',
]); ]);
Tag::factory()->forOrganization($organizationAcme)->create([ Tag::factory()->forOrganization($organizationAcme)->create([
'name' => 'Research', '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() TimeEntry::factory()
->count(10) ->count(10)
->forMember($userAcmeAdminMember) ->forMember($userAcmeAdminMember)
@@ -238,7 +219,6 @@ class DatabaseSeeder extends Seeder
// Application tables // Application tables
DB::table((new Audit)->getTable())->delete(); DB::table((new Audit)->getTable())->delete();
DB::table((new Report)->getTable())->delete(); DB::table((new Report)->getTable())->delete();
DB::table((new Goal)->getTable())->delete();
DB::table((new TimeEntry)->getTable())->delete(); DB::table((new TimeEntry)->getTable())->delete();
DB::table((new Task)->getTable())->delete(); DB::table((new Task)->getTable())->delete();
DB::table((new Tag)->getTable())->delete(); DB::table((new Tag)->getTable())->delete();

View File

@@ -292,16 +292,6 @@ test.describe('Command Palette', () => {
await expect(page.locator('[role="dialog"]').getByText('Create Tags')).toBeVisible(); 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 }) => { test('opens invite member modal', async ({ page }) => {
await goToDashboard(page); await goToDashboard(page);
await openCommandPalette(page); await openCommandPalette(page);
@@ -476,9 +466,6 @@ test.describe('Employee Command Palette Restrictions', () => {
employee.page.getByRole('option', { name: 'Invite Member' }) employee.page.getByRole('option', { name: 'Invite Member' })
).not.toBeVisible(); ).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) // Should still see Create Time Entry (employees can create time entries)
await expect( await expect(
employee.page.getByRole('option', { name: 'Create Time Entry' }) employee.page.getByRole('option', { name: 'Create Time Entry' })

Some files were not shown because too many files have changed in this diff Show More