mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 20:52:14 +01:00
Added billable and project_id to time entry endpoints; Enhanced api docs; Added pagination
This commit is contained in:
57
app/Extensions/Scramble/ApiExceptionTypeToSchema.php
Normal file
57
app/Extensions/Scramble/ApiExceptionTypeToSchema.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Extensions\Scramble;
|
||||||
|
|
||||||
|
use App\Exceptions\Api\ApiException;
|
||||||
|
use Dedoc\Scramble\Extensions\ExceptionToResponseExtension;
|
||||||
|
use Dedoc\Scramble\Support\Generator\Reference;
|
||||||
|
use Dedoc\Scramble\Support\Generator\Response;
|
||||||
|
use Dedoc\Scramble\Support\Generator\Schema;
|
||||||
|
use Dedoc\Scramble\Support\Generator\Types as OpenApiTypes;
|
||||||
|
use Dedoc\Scramble\Support\Type\ObjectType;
|
||||||
|
use Dedoc\Scramble\Support\Type\Type;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ApiExceptionTypeToSchema extends ExceptionToResponseExtension
|
||||||
|
{
|
||||||
|
public function shouldHandle(Type $type): bool
|
||||||
|
{
|
||||||
|
return $type instanceof ObjectType
|
||||||
|
&& $type->isInstanceOf(ApiException::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toResponse(Type $type): Response
|
||||||
|
{
|
||||||
|
$validationResponseBodyType = (new OpenApiTypes\ObjectType())
|
||||||
|
->addProperty(
|
||||||
|
'error',
|
||||||
|
(new OpenApiTypes\BooleanType())
|
||||||
|
->setDescription('Whether the response is an error.')
|
||||||
|
)
|
||||||
|
->addProperty(
|
||||||
|
'key',
|
||||||
|
(new OpenApiTypes\StringType())
|
||||||
|
->setDescription('Error key.')
|
||||||
|
)
|
||||||
|
->addProperty(
|
||||||
|
'message',
|
||||||
|
(new OpenApiTypes\StringType())
|
||||||
|
->setDescription('Error message.')
|
||||||
|
)
|
||||||
|
->setRequired(['error', 'key', 'message']);
|
||||||
|
|
||||||
|
return Response::make(400)
|
||||||
|
->description('API exception')
|
||||||
|
->setContent(
|
||||||
|
'application/json',
|
||||||
|
Schema::fromType($validationResponseBodyType)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reference(ObjectType $type): Reference
|
||||||
|
{
|
||||||
|
return new Reference('responses', Str::start($type->name, '\\'), $this->components);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Extensions\Scramble;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Generic $type
|
||||||
|
*/
|
||||||
|
public function toResponse(Type $type): ?Response
|
||||||
|
{
|
||||||
|
/** @var Type|null $collectingClassType */
|
||||||
|
$collectingClassType = $type->templateTypes[0];
|
||||||
|
|
||||||
|
if (! $collectingClassType instanceof ObjectType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $collectingClassType->isInstanceOf(JsonResource::class) && ! $collectingClassType->isInstanceOf(Model::class)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! ($collectingType = $this->openApiTransformer->transform($collectingClassType))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = new OpenApiObjectType;
|
||||||
|
$type->addProperty('data', (new ArrayType())->setItems($collectingType));
|
||||||
|
$type->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'])
|
||||||
|
);
|
||||||
|
$type->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'])
|
||||||
|
);
|
||||||
|
$type->setRequired(['data', 'links', 'meta']);
|
||||||
|
|
||||||
|
return Response::make(200)
|
||||||
|
->description('Paginated set of `'.$this->components->uniqueSchemaName($collectingClassType->name).'`')
|
||||||
|
->setContent('application/json', Schema::fromType($type));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ class ClientController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get clients
|
* Get clients
|
||||||
*
|
*
|
||||||
|
* @return ClientCollection<ClientResource>
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization): ClientCollection
|
public function index(Organization $organization): ClientCollection
|
||||||
@@ -35,7 +37,7 @@ class ClientController extends Controller
|
|||||||
$clients = Client::query()
|
$clients = Client::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->get();
|
->paginate();
|
||||||
|
|
||||||
return new ClientCollection($clients);
|
return new ClientCollection($clients);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ namespace App\Http\Controllers\Api\V1;
|
|||||||
|
|
||||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||||
use App\Http\Requests\V1\User\UserIndexRequest;
|
use App\Http\Requests\V1\User\UserIndexRequest;
|
||||||
use App\Http\Resources\V1\User\UserCollection;
|
use App\Http\Resources\V1\User\MemberCollection;
|
||||||
|
use App\Http\Resources\V1\User\MemberResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
@@ -14,25 +15,27 @@ use Illuminate\Http\JsonResponse;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||||
|
|
||||||
class UserController extends Controller
|
class MemberController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* List all users in an organization
|
* List all members of an organization
|
||||||
|
*
|
||||||
|
* @return MemberCollection<MemberResource>>
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization, UserIndexRequest $request): UserCollection
|
public function index(Organization $organization, UserIndexRequest $request): MemberCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'users:view');
|
$this->checkPermission($organization, 'users:view');
|
||||||
|
|
||||||
$users = $organization->users()
|
$users = $organization->users()
|
||||||
->paginate();
|
->paginate();
|
||||||
|
|
||||||
return UserCollection::make($users);
|
return MemberCollection::make($users);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invite a placeholder user to become a real user in the organization
|
* Invite a placeholder user to become a member of the organization
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException|UserNotPlaceholderApiException
|
* @throws AuthorizationException|UserNotPlaceholderApiException
|
||||||
*/
|
*/
|
||||||
@@ -51,6 +54,6 @@ class UserController extends Controller
|
|||||||
'employee'
|
'employee'
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json($user);
|
return response()->json(null, 204);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,16 +27,18 @@ class ProjectController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get projects
|
* Get projects
|
||||||
*
|
*
|
||||||
|
* @return ProjectCollection<ProjectResource>
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId getProjects
|
* @operationId getProjects
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization): JsonResource
|
public function index(Organization $organization): ProjectCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'projects:view');
|
$this->checkPermission($organization, 'projects:view');
|
||||||
$projects = Project::query()
|
$projects = Project::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->get();
|
->paginate();
|
||||||
|
|
||||||
return new ProjectCollection($projects);
|
return new ProjectCollection($projects);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ class TagController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get tags
|
* Get tags
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @return TagCollection<TagResource>
|
||||||
*
|
*
|
||||||
* @operationId getTags
|
* @operationId getTags
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization): TagCollection
|
public function index(Organization $organization): TagCollection
|
||||||
{
|
{
|
||||||
@@ -37,7 +39,7 @@ class TagController extends Controller
|
|||||||
$tags = Tag::query()
|
$tags = Tag::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->get();
|
->paginate();
|
||||||
|
|
||||||
return new TagCollection($tags);
|
return new TagCollection($tags);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Create time entry
|
* Create time entry
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException|TimeEntryStillRunningApiException
|
* @throws AuthorizationException
|
||||||
|
* @throws TimeEntryStillRunningApiException
|
||||||
*
|
*
|
||||||
* @operationId createTimeEntry
|
* @operationId createTimeEntry
|
||||||
*/
|
*/
|
||||||
@@ -117,7 +118,6 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($request->get('end') === null && TimeEntry::query()->where('user_id', $request->get('user_id'))->where('end', null)->exists()) {
|
if ($request->get('end') === null && TimeEntry::query()->where('user_id', $request->get('user_id'))->where('end', null)->exists()) {
|
||||||
// TODO: API documentation
|
|
||||||
throw new TimeEntryStillRunningApiException();
|
throw new TimeEntryStillRunningApiException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +145,8 @@ class TimeEntryController extends Controller
|
|||||||
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: TimeEntryStillRunningApiException
|
||||||
|
|
||||||
$timeEntry->fill($request->validated());
|
$timeEntry->fill($request->validated());
|
||||||
$timeEntry->description = $request->get('description', $timeEntry->description) ?? '';
|
$timeEntry->description = $request->get('description', $timeEntry->description) ?? '';
|
||||||
$timeEntry->save();
|
$timeEntry->save();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -36,6 +37,16 @@ class TimeEntryStoreRequest extends FormRequest
|
|||||||
return $builder->belongsToOrganization($this->organization);
|
return $builder->belongsToOrganization($this->organization);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
'project_id' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'uuid',
|
||||||
|
'required_with:task_id',
|
||||||
|
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Project> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
}),
|
||||||
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
'task_id' => [
|
'task_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
@@ -45,6 +56,11 @@ class TimeEntryStoreRequest extends FormRequest
|
|||||||
/** @var Builder<Task> $builder */
|
/** @var Builder<Task> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
}),
|
}),
|
||||||
|
(new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Task> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization')
|
||||||
|
->where('project_id', $this->input('project_id'));
|
||||||
|
}))->withMessage(__('validation.task_belongs_to_project')),
|
||||||
],
|
],
|
||||||
// Start of time entry (ISO 8601 format, UTC timezone)
|
// Start of time entry (ISO 8601 format, UTC timezone)
|
||||||
'start' => [
|
'start' => [
|
||||||
@@ -57,6 +73,11 @@ class TimeEntryStoreRequest extends FormRequest
|
|||||||
'date_format:Y-m-d\TH:i:s\Z',
|
'date_format:Y-m-d\TH:i:s\Z',
|
||||||
'after:start',
|
'after:start',
|
||||||
],
|
],
|
||||||
|
// Whether time entry is billable
|
||||||
|
'billable' => [
|
||||||
|
'required',
|
||||||
|
'boolean',
|
||||||
|
],
|
||||||
// Description of time entry
|
// Description of time entry
|
||||||
'description' => [
|
'description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
@@ -25,6 +26,16 @@ class TimeEntryUpdateRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'project_id' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'uuid',
|
||||||
|
'required_with:task_id',
|
||||||
|
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Project> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
}),
|
||||||
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
'task_id' => [
|
'task_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
@@ -34,19 +45,28 @@ class TimeEntryUpdateRequest extends FormRequest
|
|||||||
/** @var Builder<Task> $builder */
|
/** @var Builder<Task> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
}),
|
}),
|
||||||
|
(new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Task> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization')
|
||||||
|
->where('project_id', $this->input('project_id'));
|
||||||
|
}))->withMessage(__('validation.task_belongs_to_project')),
|
||||||
],
|
],
|
||||||
// Start of time entry (ISO 8601 format, UTC timezone)
|
// Start of time entry (ISO 8601 format, UTC timezone)
|
||||||
'start' => [
|
'start' => [
|
||||||
'required',
|
'required',
|
||||||
'date', // TODO
|
'date_format:Y-m-d\TH:i:s\Z',
|
||||||
],
|
],
|
||||||
// End of time entry (ISO 8601 format, UTC timezone)
|
// End of time entry (ISO 8601 format, UTC timezone)
|
||||||
'end' => [
|
'end' => [
|
||||||
'present',
|
'present',
|
||||||
'nullable',
|
'nullable',
|
||||||
'date', // TODO
|
'date_format:Y-m-d\TH:i:s\Z',
|
||||||
'after:start',
|
'after:start',
|
||||||
],
|
],
|
||||||
|
// Whether time entry is billable
|
||||||
|
'billable' => [
|
||||||
|
'boolean',
|
||||||
|
],
|
||||||
// Description of time entry
|
// Description of time entry
|
||||||
'description' => [
|
'description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
|
|||||||
9
app/Http/Resources/PaginatedResourceCollection.php
Normal file
9
app/Http/Resources/PaginatedResourceCollection.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
interface PaginatedResourceCollection
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Project;
|
namespace App\Http\Resources\V1\Project;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class ProjectCollection extends ResourceCollection
|
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class TimeEntryResource extends BaseResource
|
|||||||
'user_id' => $this->resource->user_id,
|
'user_id' => $this->resource->user_id,
|
||||||
/** @var array<string> $tags List of tag IDs */
|
/** @var array<string> $tags List of tag IDs */
|
||||||
'tags' => $this->resource->tags ?? [],
|
'tags' => $this->resource->tags ?? [],
|
||||||
|
/** @var bool $billable Whether time entry is billable */
|
||||||
|
'billable' => $this->resource->billable,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ namespace App\Http\Resources\V1\User;
|
|||||||
|
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class UserCollection extends ResourceCollection
|
class MemberCollection extends ResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
*
|
*
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
public $collects = UserResource::class;
|
public $collects = MemberResource::class;
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ use Illuminate\Http\Request;
|
|||||||
/**
|
/**
|
||||||
* @property User $resource
|
* @property User $resource
|
||||||
*/
|
*/
|
||||||
class UserResource extends BaseResource
|
class MemberResource extends BaseResource
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Transform the resource into an array.
|
* Transform the resource into an array.
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "8.3.*",
|
"php": "8.3.*",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"dedoc/scramble": "^0.8.5",
|
"dedoc/scramble": "dev-main",
|
||||||
"filament/filament": "^3.2",
|
"filament/filament": "^3.2",
|
||||||
"guzzlehttp/guzzle": "^7.2",
|
"guzzlehttp/guzzle": "^7.2",
|
||||||
"inertiajs/inertia-laravel": "^0.6.8",
|
"inertiajs/inertia-laravel": "^0.6.8",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"pxlrbt/filament-environment-indicator": "^2.0",
|
"pxlrbt/filament-environment-indicator": "^2.0",
|
||||||
"spatie/temporary-directory": "^2.2",
|
"spatie/temporary-directory": "^2.2",
|
||||||
"tightenco/ziggy": "^1.0",
|
"tightenco/ziggy": "^1.0",
|
||||||
"tpetry/laravel-postgresql-enhanced": "^0.33.0"
|
"tpetry/laravel-postgresql-enhanced": "^0.36.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"brianium/paratest": "^7.3",
|
"brianium/paratest": "^7.3",
|
||||||
@@ -91,6 +91,12 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"url": "https://github.com/korridor/scramble"
|
||||||
|
}
|
||||||
|
],
|
||||||
"config": {
|
"config": {
|
||||||
"optimize-autoloader": true,
|
"optimize-autoloader": true,
|
||||||
"preferred-install": "dist",
|
"preferred-install": "dist",
|
||||||
|
|||||||
591
composer.lock
generated
591
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -34,10 +34,25 @@ class TimeEntryFactory extends Factory
|
|||||||
'billable' => $this->faker->boolean(),
|
'billable' => $this->faker->boolean(),
|
||||||
'tags' => [],
|
'tags' => [],
|
||||||
'user_id' => User::factory(),
|
'user_id' => User::factory(),
|
||||||
|
'task_id' => null,
|
||||||
|
'project_id' => null,
|
||||||
'organization_id' => Organization::factory(),
|
'organization_id' => Organization::factory(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function withTask(Organization $organization): self
|
||||||
|
{
|
||||||
|
return $this->state(function (array $attributes) use (&$organization): array {
|
||||||
|
$project = Project::factory()->forOrganization($organization)->create();
|
||||||
|
$task = Task::factory()->forProject($project)->forOrganization($organization)->create();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'task_id' => $task->getKey(),
|
||||||
|
'project_id' => $task->project->getKey(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function withTags(Organization $organization): self
|
public function withTags(Organization $organization): self
|
||||||
{
|
{
|
||||||
return $this->state(function (array $attributes) use ($organization): array {
|
return $this->state(function (array $attributes) use ($organization): array {
|
||||||
|
|||||||
@@ -188,12 +188,17 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'attributes' => [],
|
'attributes' => [
|
||||||
|
'task_id' => 'task',
|
||||||
|
'project_id' => 'project',
|
||||||
|
'organization_id' => 'organization',
|
||||||
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Custom validation rules
|
* Custom validation rules
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'color' => 'The :attribute field must be a valid color.',
|
'color' => 'The :attribute field must be a valid color.',
|
||||||
|
'organization' => 'The :attribute does not exist.',
|
||||||
|
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
use App\Http\Controllers\Api\V1\ClientController;
|
use App\Http\Controllers\Api\V1\ClientController;
|
||||||
use App\Http\Controllers\Api\V1\ImportController;
|
use App\Http\Controllers\Api\V1\ImportController;
|
||||||
|
use App\Http\Controllers\Api\V1\MemberController;
|
||||||
use App\Http\Controllers\Api\V1\OrganizationController;
|
use App\Http\Controllers\Api\V1\OrganizationController;
|
||||||
use App\Http\Controllers\Api\V1\ProjectController;
|
use App\Http\Controllers\Api\V1\ProjectController;
|
||||||
use App\Http\Controllers\Api\V1\TagController;
|
use App\Http\Controllers\Api\V1\TagController;
|
||||||
use App\Http\Controllers\Api\V1\TimeEntryController;
|
use App\Http\Controllers\Api\V1\TimeEntryController;
|
||||||
use App\Http\Controllers\Api\V1\UserController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ Route::middleware('auth:api')->prefix('v1')->name('v1.')->group(static function
|
|||||||
|
|
||||||
// User routes
|
// User routes
|
||||||
Route::name('users.')->group(static function () {
|
Route::name('users.')->group(static function () {
|
||||||
Route::get('/organizations/{organization}/users', [UserController::class, 'index'])->name('index');
|
Route::get('/organizations/{organization}/members', [MemberController::class, 'index'])->name('index');
|
||||||
Route::post('/organizations/{organization}/users/{user}/invite-placeholder', [UserController::class, 'invitePlaceholder'])->name('invite-placeholder');
|
Route::post('/organizations/{organization}/members/{user}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Project routes
|
// Project routes
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertJsonCount(4, 'data');
|
$response->assertJsonCount(4, 'data');
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
$response->assertJson(fn (AssertableJson $json) => $json
|
||||||
->has('data')
|
->has('data')
|
||||||
|
->has('links')
|
||||||
|
->has('meta')
|
||||||
->count('data', 4)
|
->count('data', 4)
|
||||||
->where('data.0.id', $clients->sortByDesc('created_at')->get(0)->getKey())
|
->where('data.0.id', $clients->sortByDesc('created_at')->get(0)->getKey())
|
||||||
->where('data.1.id', $clients->sortByDesc('created_at')->get(1)->getKey())
|
->where('data.1.id', $clients->sortByDesc('created_at')->get(1)->getKey())
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class TagEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertJsonCount(4, 'data');
|
$response->assertJsonCount(4, 'data');
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
$response->assertJson(fn (AssertableJson $json) => $json
|
||||||
->has('data')
|
->has('data')
|
||||||
|
->has('links')
|
||||||
|
->has('meta')
|
||||||
->count('data', 4)
|
->count('data', 4)
|
||||||
->where('data.0.id', $tags->sortByDesc('created_at')->get(0)->getKey())
|
->where('data.0.id', $tags->sortByDesc('created_at')->get(0)->getKey())
|
||||||
->where('data.1.id', $tags->sortByDesc('created_at')->get(1)->getKey())
|
->where('data.1.id', $tags->sortByDesc('created_at')->get(1)->getKey())
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Act
|
// Act
|
||||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||||
'description' => $timeEntryFake->description,
|
'description' => $timeEntryFake->description,
|
||||||
|
'billable' => $timeEntryFake->billable,
|
||||||
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
||||||
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
||||||
'tags' => $timeEntryFake->tags,
|
'tags' => $timeEntryFake->tags,
|
||||||
@@ -332,16 +333,18 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'time-entries:create:own',
|
'time-entries:create:own',
|
||||||
]);
|
]);
|
||||||
$activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->active()->create();
|
$activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->active()->create();
|
||||||
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTags($data->organization)->make();
|
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->withTags($data->organization)->make();
|
||||||
Passport::actingAs($data->user);
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||||
'description' => $timeEntryFake->description,
|
'description' => $timeEntryFake->description,
|
||||||
|
'billable' => $timeEntryFake->billable,
|
||||||
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
||||||
'end' => null,
|
'end' => null,
|
||||||
'tags' => $timeEntryFake->tags,
|
'tags' => $timeEntryFake->tags,
|
||||||
'user_id' => $data->user->getKey(),
|
'user_id' => $data->user->getKey(),
|
||||||
|
'project_id' => $timeEntryFake->project_id,
|
||||||
'task_id' => $timeEntryFake->task_id,
|
'task_id' => $timeEntryFake->task_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -350,22 +353,53 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertJsonPath('error', true);
|
$response->assertJsonPath('error', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_store_endpoint_validation_fails_if_task_id_does_not_belong_to_project_id(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:create:own',
|
||||||
|
]);
|
||||||
|
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
|
||||||
|
$timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||||
|
'description' => $timeEntryFake->description,
|
||||||
|
'billable' => $timeEntryFake->billable,
|
||||||
|
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
||||||
|
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
||||||
|
'tags' => $timeEntryFake->tags,
|
||||||
|
'user_id' => $data->user->getKey(),
|
||||||
|
'project_id' => $timeEntryFake->project_id,
|
||||||
|
'task_id' => $timeEntryFake2->task_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(422);
|
||||||
|
$response->assertJsonValidationErrors([
|
||||||
|
'task_id' => 'The task is not part of the given project.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_store_endpoint_creates_new_time_entry_for_current_user(): void
|
public function test_store_endpoint_creates_new_time_entry_for_current_user(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$data = $this->createUserWithPermission([
|
$data = $this->createUserWithPermission([
|
||||||
'time-entries:create:own',
|
'time-entries:create:own',
|
||||||
]);
|
]);
|
||||||
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();
|
$timeEntryFake = TimeEntry::factory()->withTask($data->organization)->forOrganization($data->organization)->make();
|
||||||
Passport::actingAs($data->user);
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||||
'description' => $timeEntryFake->description,
|
'description' => $timeEntryFake->description,
|
||||||
|
'billable' => $timeEntryFake->billable,
|
||||||
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
||||||
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
||||||
'tags' => $timeEntryFake->tags,
|
'tags' => $timeEntryFake->tags,
|
||||||
'user_id' => $data->user->getKey(),
|
'user_id' => $data->user->getKey(),
|
||||||
|
'project_id' => $timeEntryFake->project_id,
|
||||||
'task_id' => $timeEntryFake->task_id,
|
'task_id' => $timeEntryFake->task_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -389,6 +423,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||||
|
'billable' => $timeEntryFake->billable,
|
||||||
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
||||||
'user_id' => $data->user->getKey(),
|
'user_id' => $data->user->getKey(),
|
||||||
]);
|
]);
|
||||||
@@ -418,10 +453,12 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Act
|
// Act
|
||||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||||
'description' => $timeEntryFake->description,
|
'description' => $timeEntryFake->description,
|
||||||
|
'billable' => $timeEntryFake->billable,
|
||||||
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
||||||
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
||||||
'tags' => $timeEntryFake->tags,
|
'tags' => $timeEntryFake->tags,
|
||||||
'user_id' => $otherUser->getKey(),
|
'user_id' => $otherUser->getKey(),
|
||||||
|
'project_id' => $timeEntryFake->project_id,
|
||||||
'task_id' => $timeEntryFake->task_id,
|
'task_id' => $timeEntryFake->task_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -445,6 +482,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Act
|
// Act
|
||||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||||
'description' => $timeEntryFake->description,
|
'description' => $timeEntryFake->description,
|
||||||
|
'billable' => $timeEntryFake->billable,
|
||||||
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
||||||
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
||||||
'tags' => $timeEntryFake->tags,
|
'tags' => $timeEntryFake->tags,
|
||||||
@@ -473,6 +511,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Act
|
// Act
|
||||||
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||||
'description' => $timeEntryFake->description,
|
'description' => $timeEntryFake->description,
|
||||||
|
'billable' => $timeEntryFake->billable,
|
||||||
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
'start' => $timeEntryFake->start->toIso8601ZuluString(),
|
||||||
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
'end' => $timeEntryFake->end->toIso8601ZuluString(),
|
||||||
'tags' => $timeEntryFake->tags,
|
'tags' => $timeEntryFake->tags,
|
||||||
|
|||||||
Reference in New Issue
Block a user