Added time entry api endpoints; Increased phpstan level; renamed to solidtime

This commit is contained in:
Constantin Graf
2024-02-26 14:27:12 +01:00
parent e60e502612
commit 9c5a238dda
36 changed files with 1716 additions and 461 deletions

View File

@@ -11,11 +11,22 @@ use App\Http\Resources\V1\Project\ProjectResource;
use App\Models\Organization;
use App\Models\Project;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
class ProjectController extends Controller
{
protected function checkPermission(Organization $organization, string $permission, ?Project $project = null): void
{
parent::checkPermission($organization, $permission);
if ($project !== null && $project->organization_id !== $organization->id) {
throw new AuthorizationException('Project does not belong to organization');
}
}
/**
* Get projects
*
* @throws AuthorizationException
*/
public function index(Organization $organization): JsonResource
@@ -29,17 +40,22 @@ class ProjectController extends Controller
}
/**
* Get project
*
* @throws AuthorizationException
*/
public function show(Organization $organization, Project $project): JsonResource
{
$this->checkPermission($organization, 'projects:view');
$this->checkPermission($organization, 'projects:view', $project);
$project->load('organization');
return new ProjectResource($project);
}
/**
* Create project
*
* @throws AuthorizationException
*/
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
@@ -55,11 +71,13 @@ class ProjectController extends Controller
}
/**
* Update project
*
* @throws AuthorizationException
*/
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request): JsonResource
{
$this->checkPermission($organization, 'projects:update');
$this->checkPermission($organization, 'projects:update', $project);
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->save();
@@ -68,13 +86,17 @@ class ProjectController extends Controller
}
/**
* Delete project
*
* @throws AuthorizationException
*/
public function destroy(Organization $organization, Project $project): JsonResource
public function destroy(Organization $organization, Project $project): JsonResponse
{
$this->checkPermission($organization, 'projects:delete');
$this->checkPermission($organization, 'projects:delete', $project);
$project->delete();
return new ProjectResource($project);
return response()
->json(null, 204);
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\TimeEntryStillRunning;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest;
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
use App\Http\Resources\V1\TimeEntry\TimeEntryResource;
use App\Models\Organization;
use App\Models\TimeEntry;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Auth;
class TimeEntryController extends Controller
{
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
{
parent::checkPermission($organization, $permission);
if ($timeEntry !== null && $timeEntry->organization_id !== $organization->getKey()) {
throw new AuthorizationException('Time entry does not belong to organization');
}
}
/**
* Get time entries
*
* @throws AuthorizationException
*/
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
{
if ($request->has('user_id') && $request->get('user_id') === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization');
if ($request->has('before')) {
}
if ($request->has('after')) {
}
if ($request->has('user_id')) {
$timeEntriesQuery->where('user_id', $request->input('user_id'));
}
$timeEntries = $timeEntriesQuery->get();
return new TimeEntryCollection($timeEntries);
}
/**
* Create time entry
*
* @throws AuthorizationException|TimeEntryStillRunning
*/
public function store(Organization $organization, TimeEntryStoreRequest $request): JsonResource
{
if ($request->get('user_id') === Auth::id()) {
$this->checkPermission($organization, 'time-entries:create:own');
} else {
$this->checkPermission($organization, 'time-entries:create:all');
}
if ($request->get('end') === null && TimeEntry::where('user_id', $request->get('user_id'))->where('end', null)->exists()) {
// TODO: documentation
throw new TimeEntryStillRunning('User already has an active time entry');
}
$timeEntry = new TimeEntry();
$timeEntry->fill($request->validated());
$timeEntry->organization()->associate($organization);
$timeEntry->save();
return new TimeEntryResource($timeEntry);
}
/**
* Update time entry
*
* @throws AuthorizationException
*/
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
{
if ($timeEntry->user_id === Auth::id() && $request->get('user_id') === Auth::id()) {
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
} else {
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
}
$timeEntry->fill($request->validated());
$timeEntry->save();
return new TimeEntryResource($timeEntry);
}
/**
* Delete time entry
*
* @throws AuthorizationException
*/
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
{
if ($timeEntry->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
} else {
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
}
$timeEntry->delete();
return response()
->json(null, 204);
}
}

View File

@@ -32,6 +32,8 @@ class HandleInertiaRequests extends Middleware
* Defines the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{

View File

@@ -26,5 +26,6 @@ class TrustProxies extends Middleware
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
Request::HEADER_X_FORWARDED_AWS_ELB |
Request::HEADER_X_FORWARDED_TRAEFIK;
}

View File

@@ -12,7 +12,7 @@ class ProjectStoreRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{

View File

@@ -12,7 +12,7 @@ class ProjectUpdateRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryIndexRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'user_id' => [
'string',
'uuid',
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
/** @var Builder<User> $builder */
return $builder->whereHas('organizations', function (Builder $builder) {
/** @var Builder<Organization> $builder */
return $builder->whereKey($this->organization->getKey());
});
}),
],
'before' => [
'nullable',
'string',
'date_format:Y-m-d',
'before:after',
],
'after' => [
'nullable',
'string',
'date_format:Y-m-d',
],
];
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Models\Organization;
use App\Models\Tag;
use App\Models\Task;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'user_id' => [
'required',
'string',
'uuid',
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
/** @var Builder<User> $builder */
return $builder->whereHas('organizations', function (Builder $builder) {
/** @var Builder<Organization> $builder */
return $builder->whereKey($this->organization->getKey());
});
}),
],
'task_id' => [
'nullable',
'string',
'uuid',
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Start of time entry (ISO 8601 format, UTC timezone)
'start' => [
'required',
'date', // TODO
],
// End of time entry (ISO 8601 format, UTC timezone)
'end' => [
'required',
'nullable',
'date', // TODO
'after:start',
],
// Description of time entry
'description' => [
'nullable',
'string',
'max:255',
],
// List of tag IDs
'tags' => [
'nullable',
'array',
],
'tags.*' => [
'string',
'uuid',
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Models\Organization;
use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'task_id' => [
'nullable',
'string',
'uuid',
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Start of time entry (ISO 8601 format, UTC timezone)
'start' => [
'required',
'date', // TODO
],
// End of time entry (ISO 8601 format, UTC timezone)
'end' => [
'required',
'nullable',
'date', // TODO
'after:start',
],
// Description of time entry
'description' => [
'nullable',
'string',
'max:255',
],
// List of tag IDs
'tags' => [
'nullable',
'array',
],
'tags.*' => [
'string',
'uuid',
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\TimeEntry;
use Illuminate\Http\Resources\Json\ResourceCollection;
class TimeEntryCollection extends ResourceCollection
{
/**
* The resource that this resource collects.
*
* @var string
*/
public $collects = TimeEntryResource::class;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\TimeEntry;
use App\Models\TimeEntry;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property TimeEntry $resource
*/
class TimeEntryResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|boolean|integer>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of time entry */
'id' => $this->resource->id,
];
}
}