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

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
@@ -11,7 +12,7 @@ trait PasswordValidationRules
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array|string>
* @return array<int, Rule|string>
*/
protected function passwordRules(): array
{

View File

@@ -7,6 +7,7 @@ namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Models\User;
use Closure;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Laravel\Jetstream\Contracts\AddsTeamMembers;
@@ -55,7 +56,7 @@ class AddOrganizationMember implements AddsTeamMembers
/**
* Get the validation rules for adding a team member.
*
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
* @return array<string, array<Rule|string>>
*/
protected function rules(): array
{

View File

@@ -8,6 +8,7 @@ use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\User;
use Closure;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Gate;
@@ -59,7 +60,7 @@ class InviteOrganizationMember implements InvitesTeamMembers
/**
* Get the validation rules for inviting a team member.
*
* @return array<string, ValidationRule|array|string>
* @return array<string, array<ValidationRule|Rule|string>>
*/
protected function rules(Organization $organization): array
{

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ApiException extends Exception
{
/**
* Render the exception into an HTTP response.
*/
public function render(Request $request): JsonResponse
{
return response()
->json([
'error' => true,
'message' => $this->getMessage(),
], 400);
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
class TimeEntryStillRunning extends ApiException
{
}

View File

@@ -25,15 +25,16 @@ class OrganizationResource extends Resource
public static function form(Form $form): Form
{
return $form
->columns(1)
->schema([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\Toggle::make('Is personal?')
Forms\Components\Toggle::make('personal_team')
->label('Is personal?')
->required(),
Forms\Components\Select::make('owner_id')
Forms\Components\Select::make('user_id')
->relationship(name: 'owner', titleAttribute: 'email')
->searchable(['name', 'email'])
->required(),
@@ -47,7 +48,8 @@ class OrganizationResource extends Resource
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\ToggleColumn::make('is_personal')
Tables\Columns\IconColumn::make('personal_team')
->boolean()
->label('Is personal?')
->sortable(),
Tables\Columns\TextColumn::make('owner.email')

View File

@@ -25,6 +25,7 @@ class UserResource extends Resource
public static function form(Form $form): Form
{
return $form
->columns(1)
->schema([
Forms\Components\TextInput::make('id')
->label('ID')

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,
];
}
}

View File

@@ -36,6 +36,8 @@ class OrganizationInvitation extends JetstreamTeamInvitation
/**
* Get the organization that the invitation belongs to.
*
* @return BelongsTo<Organization, OrganizationInvitation>
*/
public function organization(): BelongsTo
{
@@ -44,6 +46,8 @@ class OrganizationInvitation extends JetstreamTeamInvitation
/**
* Get the organization that the invitation belongs to.
*
* @return BelongsTo<Organization, OrganizationInvitation>
*/
public function team(): BelongsTo
{

View File

@@ -19,9 +19,13 @@ use Illuminate\Support\Carbon;
* @property Carbon|null $end
* @property bool $billable
* @property array $tags
* @property string $user_id
* @property-read User $user
* @property string $organization_id
* @property-read Organization $organization
* @property string|null $project_id
* @property-read Project|null $project
* @property string|null $task_id
* @property-read Task|null $task
*
* @method static TimeEntryFactory factory()

View File

@@ -13,9 +13,15 @@ use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
use Dedoc\Scramble\Support\Generator\SecuritySchemes\OAuthFlow;
use Filament\Forms\Components\Section;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -55,5 +61,19 @@ class AppServiceProvider extends ServiceProvider
Section::configureUsing(function (Section $section): void {
$section->columns(1);
}, null, true);
Scramble::extendOpenApi(function (OpenApi $openApi) {
$openApi->secure(
SecurityScheme::oauth2()
->flow('authorizationCode', function (OAuthFlow $flow) {
$flow
->authorizationUrl('https://solidtime.test/oauth/authorize');
})
);
});
if (config('app.force_https', false) || App::isProduction()) {
URL::forceScheme('https');
}
}
}

View File

@@ -56,6 +56,14 @@ class JetstreamServiceProvider extends ServiceProvider
'projects:create',
'projects:update',
'projects:delete',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
'time-entries:delete:all',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
])->description('Administrator users can perform any action.');
Jetstream::role('manager', 'Manager', [
@@ -63,13 +71,22 @@ class JetstreamServiceProvider extends ServiceProvider
'projects:create',
'projects:update',
'projects:delete',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
'time-entries:delete:all',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
])->description('Editor users have the ability to read, create, and update.');
Jetstream::role('employee', 'Employee', [
'projects:view',
'projects:create',
'projects:update',
'projects:delete',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
])->description('Editor users have the ability to read, create, and update.');
}
}