Compare commits

..

1 Commits

Author SHA1 Message Date
Gregor Vostrak
70f483b13b improve API docs 2026-10-05 16:15:27 +02:00
60 changed files with 291 additions and 940 deletions

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,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
*
* @return ClientCollection<ClientResource>
*
* @throws AuthorizationException
*
* @operationId getClients

View File

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

View File

@@ -48,6 +48,8 @@ class MemberController extends Controller
/**
* List all members of an organization
*
* @return MemberCollection<MemberResource>
*
* @throws AuthorizationException
*
* @operationId getMembers

View File

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

View File

@@ -36,6 +36,8 @@ class ProjectMemberController extends Controller
/**
* Get project members for project
*
* @return ProjectMemberCollection<ProjectMemberResource>
*
* @throws AuthorizationException
*
* @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\Resources\V1\Report\DetailedReportResource;
use App\Http\Resources\V1\Report\ReportCollection;
use App\Http\Resources\V1\Report\ReportResource;
use App\Models\Organization;
use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto;
@@ -34,6 +35,8 @@ class ReportController extends Controller
/**
* Get reports
*
* @return ReportCollection<ReportResource>
*
* @throws AuthorizationException
*
* @operationId getReports

View File

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

View File

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

View File

@@ -117,6 +117,8 @@ class TimeEntryController extends Controller
* 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`).
*
* @return TimeEntryCollection<TimeEntryResource>
*
* @throws AuthorizationException
*
* @operationId getTimeEntries

View File

@@ -43,10 +43,9 @@ class UserUpdateRequest extends BaseFormRequest
'email' => [
'email:rfc,strict',
'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 */
return $query->where('is_placeholder', '=', false)
->whereKeyNot($this->user->getKey());
return $query->where('is_placeholder', '=', false);
}),
],
'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
{
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,
/** @var string $name Name of the API token */
'name' => $this->resource->name,
/** Whether the API token is revoked */
/** @var bool $revoked Whether the API token is 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,
/** 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),
/** @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),

View File

@@ -29,15 +29,15 @@ class ApiTokenWithAccessTokenResource extends BaseResource
public function toArray(Request $request): array
{
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,
/** @var string $name Name of the API token */
'name' => $this->resource->name,
/** Whether the API token is revoked */
/** @var bool $revoked Whether the API token is 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,
/** 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),
/** @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),

View File

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

View File

@@ -21,15 +21,15 @@ class ClientResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID */
/** @var string $id ID */
'id' => $this->resource->id,
/** Name */
/** @var string $name 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,
/** When the tag was created */
/** @var string $created_at When the tag was created */
'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),
];
}

View File

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

View File

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

View File

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

View File

@@ -22,19 +22,19 @@ class MemberResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of membership */
/** @var string $id ID of membership */
'id' => $this->resource->id,
/** ID of user */
/** @var string $id ID of user */
'user_id' => $this->resource->user->id,
/** Name */
/** @var string $name Name */
'name' => $this->resource->user->name,
/** Email */
/** @var string $email Email */
'email' => $this->resource->user->email,
/** Role */
/** @var string $role 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,
/** Billable rate in cents per hour */
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,
];
}

View File

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

View File

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

View File

@@ -43,23 +43,23 @@ class OrganizationResource extends BaseResource
$currencyService = app(CurrencyService::class);
return [
/** ID */
/** @var string $id ID */
'id' => $this->resource->id,
/** Name */
/** @var string $name 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,
/** 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,
/** 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,
/** 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,
/** 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,
/** 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,
/** Currency code (ISO 4217) */
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),

View File

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

View File

@@ -30,25 +30,25 @@ class ProjectResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of project */
/** @var string $id ID of project */
'id' => $this->resource->id,
/** Name of project */
/** @var string $name Name of project */
'name' => $this->resource->name,
/** Color of project */
/** @var string $color Color of project */
'color' => $this->resource->color,
/** @var string|null $client_id ID of client */
'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,
/** 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,
/** Project time entries billable default */
/** @var bool $is_billable Project time entries billable default */
'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,
/** 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,
/** Whether the project is public */
/** @var bool $is_public Whether the project is public */
'is_public' => $this->resource->is_public,
];
}

View File

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

View File

@@ -21,13 +21,13 @@ class ProjectMemberResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of project member */
/** @var string $id ID of project member */
'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,
/** ID of the organization member */
/** @var string $member_id ID of the organization member */
'member_id' => $this->resource->member_id,
/** ID of the project */
/** @var string $project_id ID of the project */
'project_id' => $this->resource->project_id,
];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,13 +21,13 @@ class TagResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID */
/** @var string $id ID */
'id' => $this->resource->id,
/** Name */
/** @var string $name 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),
/** When the tag was last updated */
/** @var string $updated_at When the tag 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\Task;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class TaskCollection extends ResourceCollection
class TaskCollection extends ResourceCollection implements PaginatedResourceCollection
{
/**
* The resource that this resource collects.

View File

@@ -22,21 +22,21 @@ class TaskResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID */
/** @var string $id ID */
'id' => $this->resource->id,
/** Name */
/** @var string $name 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,
/** ID of the project */
/** @var string $project_id ID of the project */
'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,
/** 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,
/** When the tag was created */
/** @var string $created_at When the tag was created */
'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),
];
}

View File

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

View File

@@ -21,10 +21,10 @@ class TimeEntryResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of time entry */
/** @var string $id ID of time entry */
'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),
/**
@@ -35,19 +35,19 @@ class TimeEntryResource extends BaseResource
'duration' => (int) $this->resource->getDuration()?->totalSeconds,
/** @var string|null $description Description of time entry */
'description' => $this->resource->description,
/** ID of task */
/** @var string|null $task_id ID of task */
'task_id' => $this->resource->task_id,
/** ID of project */
/** @var string|null $project_id ID of project */
'project_id' => $this->resource->project_id,
/** ID of organization */
/** @var string $organization_id ID of organization */
'organization_id' => $this->resource->organization_id,
/** ID of user */
/** @var string $user_id ID of user */
'user_id' => $this->resource->user_id,
/** List of tag IDs */
/** @var array<string> $tags List of tag IDs */
'tags' => $this->resource->tags ?? [],
/** Whether time entry is billable */
/** @var bool $billable Whether time entry is 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,
];
}

View File

@@ -22,21 +22,21 @@ class UserResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of user */
/** @var string $id ID of user */
'id' => $this->resource->id,
/** Name of user */
/** @var string $name Name of user */
'name' => $this->resource->name,
/** Email of user */
/** @var string $email Email of user */
'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,
/** Profile photo URL */
/** @var string $profile_photo_url 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,
/** @var Weekday $week_start Starting day of the week */
'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,
];
}

View File

@@ -4,19 +4,15 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\AuditableWithoutOwner;
use Database\Factories\AuditFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Models\Audit as PackageAuditModel;
/**
* @property int $id
* @property string|null $actor_type
* @property string|null $actor_id
* @property string|null $user_type
* @property string|null $user_id
* @property string $event
* @property string $auditable_type
* @property string $auditable_id
@@ -26,71 +22,13 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
* @property string|null $ip_address
* @property string|null $user_agent
* @property string|null $tags
* @property string|null $owner_user_id
* @property string|null $owner_organization_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read User|null $ownerUser
* @property-read Organization|null $ownerOrganization
*
* @method static AuditFactory factory()
* @method static Builder<Audit> whereMissingOwner()
*/
class Audit extends PackageAuditModel
{
/** @use HasFactory<AuditFactory> */
use HasFactory;
/**
* @return BelongsTo<User, $this>
*/
public function ownerUser(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
/**
* @return BelongsTo<Organization, $this>
*/
public function ownerOrganization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'owner_organization_id');
}
/**
* Whether audits of the given auditable type intentionally have no owner (see AuditableWithoutOwner).
*/
public static function isAuditableTypeWithoutOwner(string $auditableType): bool
{
$modelClass = Relation::getMorphedModel($auditableType) ?? $auditableType;
return is_subclass_of($modelClass, AuditableWithoutOwner::class);
}
/**
* Auditable types (morph aliases) whose audits intentionally have no owner (see AuditableWithoutOwner).
*
* @return array<int, string>
*/
public static function getAuditableTypesWithoutOwner(): array
{
return collect(Relation::morphMap())
->filter(fn (string $modelClass): bool => is_subclass_of($modelClass, AuditableWithoutOwner::class))
->keys()
->values()
->all();
}
/**
* Audits that have neither an owner organization nor an owner user, although their auditable type should have one.
* These are audits whose owner no longer exists or could not be determined (yet).
*
* @param Builder<Audit> $builder
*/
public function scopeWhereMissingOwner(Builder $builder): void
{
$builder->whereNull('owner_organization_id')
->whereNull('owner_user_id')
->whereNotIn('auditable_type', self::getAuditableTypesWithoutOwner());
}
}

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;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable;
trait CustomAuditable
@@ -21,92 +19,4 @@ trait CustomAuditable
{
$this->auditEvents = [];
}
/**
* The organization that owns the audited model.
* The audits of the model are deleted (via foreign key cascade) when the organization is deleted.
*/
public function getAuditOwnerOrganizationId(): ?string
{
if ($this instanceof AuditableThroughParent) {
$relation = $this->getAuditParentRelation();
$parentId = $this->getAttributes()[$relation->getForeignKeyName()] ?? null;
if ($parentId === null) {
return null;
}
// Note: The loaded parent is only used if it still matches the foreign key and was loaded with the organization
$relationName = $relation->getRelationName();
if ($this->relationLoaded($relationName)) {
$parent = $this->getRelation($relationName);
if ($parent instanceof Model
&& $parent->getAttribute($relation->getOwnerKeyName()) === $parentId
&& array_key_exists('organization_id', $parent->getAttributes())) {
/** @var string|null $organizationId */
$organizationId = $parent->getAttributes()['organization_id'];
return $organizationId;
}
}
/** @var string|null $organizationId */
$organizationId = $relation->getRelated()->newQuery()
->toBase()
->where($relation->getOwnerKeyName(), $parentId)
->value('organization_id');
return $organizationId;
}
return $this->getAttributes()['organization_id'] ?? null;
}
/**
* The user that owns the audited model.
* The audits of the model are deleted (via foreign key cascade) when the user is deleted.
*/
public function getAuditOwnerUserId(): ?string
{
return null;
}
/**
* Models that are the owner of their own audits can not record the deletion audit,
* since the audit would reference the already deleted model and therefore violate the foreign key.
*/
protected function isAuditOwnerOfItself(): bool
{
return false;
}
/**
* @return array<int|string, string>
*/
public function getAuditEvents(): array
{
$events = $this->auditEvents ?? Config::get('audit.events', [
'created',
'updated',
'deleted',
'restored',
]);
if ($this->isAuditOwnerOfItself()) {
$events = array_filter($events, fn (string $value, int|string $key): bool => (is_int($key) ? $value : $key) !== 'deleted', ARRAY_FILTER_USE_BOTH);
}
return $events;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function transformAudit(array $data): array
{
$data['owner_organization_id'] = $this->getAuditOwnerOrganizationId();
$data['owner_user_id'] = $this->getAuditOwnerUserId();
return $data;
}
}

View File

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

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\AuditableThroughParent;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\ProjectMemberFactory;
@@ -30,7 +29,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
* @method static ProjectMemberFactory factory()
*/
class ProjectMember extends Model implements AuditableContract, AuditableThroughParent
class ProjectMember extends Model implements AuditableContract
{
use CustomAuditable;
@@ -83,12 +82,4 @@ class ProjectMember extends Model implements AuditableContract, AuditableThrough
$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,
];
public function getAuditOwnerUserId(): ?string
{
return $this->getKey();
}
protected function isAuditOwnerOfItself(): bool
{
return true;
}
/**
* Get the URL to the user's profile photo.
*

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Extensions\Scramble\ApiExceptionTypeToSchema;
use App\Extensions\Scramble\PaginatedResourceCollectionTypeToSchema;
use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess;
return [
@@ -100,5 +101,6 @@ MD,
'extensions' => [
ApiExceptionTypeToSchema::class,
PaginatedResourceCollectionTypeToSchema::class,
],
];

View File

@@ -91,7 +91,7 @@ class UserFactory extends Factory
public function withProfilePicture(): static
{
$profilePhoto = $this->generateProfilePhoto();
$profilePhoto = $this->faker->image(null, 500, 500);
/** @see FileHelpers::hashName */
$path = 'profile-photos/'.Str::random(40).'.png';
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.
*/

View File

@@ -18,8 +18,7 @@ class CreateAuditsTable extends Migration
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 = 'user';
$morphPrefix = config('audit.user.morph_prefix', 'user');
$table->bigIncrements('id');
$table->string($morphPrefix.'_type')->nullable();

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

@@ -1,18 +1,18 @@
{
"Billing": {
"repository": "solidtime-io/extension-billing",
"ref": "v0.0.9"
"ref": "v0.0.8"
},
"Services": {
"repository": "solidtime-io/extension-services",
"ref": "v0.0.4"
"ref": "v0.0.3"
},
"Invoicing": {
"repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.9"
"ref": "v0.0.7"
},
"Auditing": {
"repository": "solidtime-io/extension-auditing",
"ref": "v0.0.4"
"ref": "v0.0.2"
}
}

View File

@@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Http\Controllers\Api\FallbackController;
use App\Http\Controllers\Api\V1\ApiTokenController;
use App\Http\Controllers\Api\V1\ChartController;
use App\Http\Controllers\Api\V1\ClientController;
@@ -24,6 +23,7 @@ use App\Http\Controllers\Api\V1\UserController;
use App\Http\Controllers\Api\V1\UserMembershipController;
use App\Http\Controllers\Api\V1\UserTimeEntryController;
use Illuminate\Support\Facades\Route;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/*
|--------------------------------------------------------------------------
@@ -200,5 +200,9 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
* Fallback routes, to prevent a rendered HTML page in /api/* routes
* The / route is also included since the fallback is not triggered on the root route
*/
Route::get('/', FallbackController::class);
Route::fallback(FallbackController::class);
Route::get('/', function (): void {
throw new NotFoundHttpException('API resource not found');
});
Route::fallback(function (): void {
throw new NotFoundHttpException('API resource not found');
});

View File

@@ -1,135 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\ApiDocs;
use App\Http\Controllers\Api\FallbackController;
use Dedoc\Scramble\Contracts\Diagnostics\Diagnostic;
use Dedoc\Scramble\Generator;
use Dedoc\Scramble\Infer\Context;
use Dedoc\Scramble\Scramble;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(FallbackController::class)]
class ApiDocsExportTest extends TestCaseWithDatabase
{
private string $path;
protected function setUp(): void
{
parent::setUp();
// Scramble keeps its inference context in a static property, which would otherwise outlive the refreshed application
Context::reset();
$this->path = storage_path('framework/testing/api-docs-'.uniqid().'.json');
File::ensureDirectoryExists(dirname($this->path));
}
protected function tearDown(): void
{
File::delete($this->path);
parent::tearDown();
}
/**
* @return array<string, mixed>
*/
private function exportApiDocs(): array
{
$exitCode = $this->withoutMockingConsoleOutput()->artisan('scramble:export', [
'--path' => $this->path,
]);
$this->assertSame(Command::SUCCESS, $exitCode);
$this->assertFileExists($this->path);
return json_decode(File::get($this->path), true, flags: JSON_THROW_ON_ERROR);
}
/**
* @param array<string, mixed> $docs
* @param array<string, mixed> $schema
* @return array<string, mixed>
*/
private function resolveSchema(array $docs, array $schema): array
{
if (isset($schema['$ref'])) {
$name = str_replace('#/components/schemas/', '', $schema['$ref']);
return $this->resolveSchema($docs, $docs['components']['schemas'][$name]);
}
return $schema;
}
public function test_api_docs_can_be_exported(): void
{
// Act
$docs = $this->exportApiDocs();
// Assert
$this->assertArrayHasKey('paths', $docs);
$this->assertNotEmpty($docs['paths']);
}
public function test_api_docs_are_generated_without_diagnostics(): void
{
// Act
$result = app(Generator::class)->generate(Scramble::getGeneratorConfig('default'));
// Assert
$diagnostics = $result->diagnostics()->map(function (Diagnostic $diagnostic): string {
$location = $diagnostic->codeLocation();
return sprintf(
'[%s] %s%s',
$diagnostic->code(),
$diagnostic->message(),
$location !== null ? ' ('.$location->file.':'.$location->line.')' : ''
);
})->all();
$this->assertSame([], $diagnostics);
}
public function test_paginated_endpoints_are_documented_with_pagination(): void
{
// Act
$docs = $this->exportApiDocs();
// Assert
$tagsSchema = $docs['paths']['/v1/organizations/{organization}/tags']['get']['responses']['200']['content']['application/json']['schema'];
$this->assertSame(['data', 'links', 'meta'], $tagsSchema['required']);
$tagsData = $this->resolveSchema($docs, $tagsSchema['properties']['data']);
$this->assertSame('array', $tagsData['type']);
$this->assertSame('#/components/schemas/TagResource', $tagsData['items']['$ref']);
$timeEntriesSchema = $docs['paths']['/v1/organizations/{organization}/time-entries']['get']['responses']['200']['content']['application/json']['schema'];
$this->assertSame(['data', 'meta'], $timeEntriesSchema['required']);
$this->assertSame(['total'], $timeEntriesSchema['properties']['meta']['required']);
}
public function test_fallback_routes_are_excluded_from_api_docs(): void
{
// Act
$docs = $this->exportApiDocs();
// Assert
$this->assertArrayNotHasKey('/', $docs['paths']);
$this->assertArrayNotHasKey('/{fallbackPlaceholder}', $docs['paths']);
}
public function test_fallback_routes_return_not_found(): void
{
// Act
$rootResponse = $this->getJson('/api');
$unknownResponse = $this->getJson('/api/does-not-exist');
// Assert
$rootResponse->assertNotFound();
$rootResponse->assertJsonPath('message', 'API resource not found');
$unknownResponse->assertNotFound();
$unknownResponse->assertJsonPath('message', 'API resource not found');
}
}

View File

@@ -245,44 +245,6 @@ class UserEndpointTest extends ApiEndpointTestAbstract
Mail::assertNothingSent();
}
public function test_update_fails_if_email_is_already_used_by_another_user(): void
{
// Arrange
Mail::fake();
$data = $this->createUserWithPermission();
User::factory()->create(['email' => 'taken@example.com']);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'email' => 'taken@example.com',
]);
// Assert
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['email']);
$this->assertNull($data->user->fresh()->pending_email);
Mail::assertNothingSent();
}
public function test_update_email_succeeds_if_email_is_only_used_by_a_placeholder_user(): void
{
// Arrange
Mail::fake();
$data = $this->createUserWithPermission();
User::factory()->placeholder()->create(['email' => 'placeholder@example.com']);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'email' => 'placeholder@example.com',
]);
// Assert
$response->assertSuccessful();
$this->assertSame('placeholder@example.com', $data->user->fresh()->pending_email);
}
public function test_update_fails_if_email_format_is_invalid(): void
{
// Arrange

View File

@@ -50,8 +50,7 @@ class TrustHostsTest extends TestCase
$this->middleware()->handle($request, fn (Request $request): Response => new Response('passed'));
try {
// Note: Throws an exception if the host is not trusted
$request->getHost();
dump($request->getHost());
return true;
} catch (\Throwable) {

View File

@@ -1,209 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Model;
use App\Models\Audit;
use App\Models\Concerns\AuditableWithoutOwner;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\DB;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(Audit::class)]
class AuditModelTest extends ModelTestAbstract
{
public function test_it_belongs_to_an_owner_organization(): void
{
// Arrange
$organization = Organization::factory()->create();
$audit = Audit::factory()->create([
'owner_organization_id' => $organization->getKey(),
]);
// Act
$audit->refresh();
$ownerOrganizationRel = $audit->ownerOrganization;
// Assert
$this->assertNotNull($ownerOrganizationRel);
$this->assertTrue($ownerOrganizationRel->is($organization));
}
public function test_it_belongs_to_an_owner_user(): void
{
// Arrange
$user = User::factory()->create();
$audit = Audit::factory()->create([
'owner_user_id' => $user->getKey(),
]);
// Act
$audit->refresh();
$ownerUserRel = $audit->ownerUser;
// Assert
$this->assertNotNull($ownerUserRel);
$this->assertTrue($ownerUserRel->is($user));
}
public function test_audits_of_models_with_organization_have_the_organization_as_owner(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
// Act
$timeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->create();
// Assert
$audit = Audit::query()->where('auditable_id', $timeEntry->getKey())->sole();
$this->assertSame($organization->getKey(), $audit->owner_organization_id);
$this->assertNull($audit->owner_user_id);
}
public function test_audits_of_project_members_have_the_organization_of_the_project_as_owner(): void
{
// Arrange
$organization = Organization::factory()->create();
$project = Project::factory()->forOrganization($organization)->create();
$member = Member::factory()->forOrganization($organization)->create();
// Act
$projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create();
// Assert
$audit = Audit::query()->where('auditable_id', $projectMember->getKey())->sole();
$this->assertSame($organization->getKey(), $audit->owner_organization_id);
$this->assertNull($audit->owner_user_id);
}
public function test_owner_organization_through_parent_uses_the_loaded_parent_without_query(): void
{
// Arrange
$organization = Organization::factory()->create();
$project = Project::factory()->forOrganization($organization)->create();
$projectMember = ProjectMember::factory()->forProject($project)->forMember(Member::factory()->forOrganization($organization)->create())->create();
$projectMember->load('project');
DB::enableQueryLog();
// Act
$ownerOrganizationId = $projectMember->getAuditOwnerOrganizationId();
// Assert
$this->assertSame($organization->getKey(), $ownerOrganizationId);
$this->assertCount(0, DB::getQueryLog());
}
public function test_owner_organization_through_parent_ignores_a_loaded_parent_that_does_not_match_the_foreign_key(): void
{
// Arrange
$organization = Organization::factory()->create();
$otherOrganization = Organization::factory()->create();
$project = Project::factory()->forOrganization($organization)->create();
$otherProject = Project::factory()->forOrganization($otherOrganization)->create();
$projectMember = ProjectMember::factory()->forProject($project)->forMember(Member::factory()->forOrganization($organization)->create())->create();
$projectMember->load('project');
$projectMember->project_id = $otherProject->getKey();
// Act
$ownerOrganizationId = $projectMember->getAuditOwnerOrganizationId();
// Assert
$this->assertSame($otherOrganization->getKey(), $ownerOrganizationId);
}
public function test_audits_of_an_organization_have_the_organization_itself_as_owner(): void
{
// Act
$organization = Organization::factory()->create();
// Assert
$audit = Audit::query()->where('auditable_id', $organization->getKey())->sole();
$this->assertSame($organization->getKey(), $audit->owner_organization_id);
$this->assertNull($audit->owner_user_id);
}
public function test_audits_of_a_user_have_the_user_itself_as_owner(): void
{
// Act
$user = User::factory()->create();
// Assert
$audit = Audit::query()->where('auditable_id', $user->getKey())->sole();
$this->assertSame($user->getKey(), $audit->owner_user_id);
$this->assertNull($audit->owner_organization_id);
}
public function test_deleting_an_owner_deletes_its_audits_and_does_not_create_a_deletion_audit(): void
{
// Arrange
$user = User::factory()->create();
$organization = Organization::factory()->create();
$otherUser = User::factory()->create();
// Act
$user->delete();
$organization->delete();
// Assert
$this->assertSame(0, Audit::query()->where('auditable_id', $user->getKey())->count());
$this->assertSame(0, Audit::query()->where('auditable_id', $organization->getKey())->count());
$this->assertSame(1, Audit::query()->where('auditable_id', $otherUser->getKey())->count());
}
public function test_auditable_types_without_owner_are_determined_by_the_marker_interface(): void
{
// Arrange
$originalMorphMap = Relation::morphMap();
$modelWithoutOwner = new class extends Model implements AuditableWithoutOwner {};
Relation::morphMap(['model-without-owner' => $modelWithoutOwner::class]);
try {
// Act
$typesWithoutOwner = Audit::getAuditableTypesWithoutOwner();
$isWithoutOwner = Audit::isAuditableTypeWithoutOwner('model-without-owner');
$isTimeEntryWithoutOwner = Audit::isAuditableTypeWithoutOwner((new TimeEntry)->getMorphClass());
// Assert
$this->assertContains('model-without-owner', $typesWithoutOwner);
$this->assertNotContains((new TimeEntry)->getMorphClass(), $typesWithoutOwner);
$this->assertTrue($isWithoutOwner);
$this->assertFalse($isTimeEntryWithoutOwner);
} finally {
Relation::morphMap($originalMorphMap, false);
}
}
public function test_scope_where_missing_owner_only_returns_audits_without_owner_whose_type_should_have_one(): void
{
// Arrange
$originalMorphMap = Relation::morphMap();
$modelWithoutOwner = new class extends Model implements AuditableWithoutOwner {};
Relation::morphMap(['model-without-owner' => $modelWithoutOwner::class]);
$organization = Organization::factory()->create();
$user = User::factory()->create();
Audit::query()->delete();
$missingOwnerAudit = Audit::factory()->create(['auditable_type' => (new TimeEntry)->getMorphClass()]);
Audit::factory()->create(['auditable_type' => (new TimeEntry)->getMorphClass(), 'owner_organization_id' => $organization->getKey()]);
Audit::factory()->create(['owner_user_id' => $user->getKey()]);
Audit::factory()->create(['auditable_type' => 'model-without-owner']);
try {
// Act
$auditIds = Audit::query()->whereMissingOwner()->pluck('id')->all();
// Assert
$this->assertSame([$missingOwnerAudit->getKey()], $auditIds);
} finally {
Relation::morphMap($originalMorphMap, false);
}
}
}

View File

@@ -7,7 +7,6 @@ namespace Tests\Unit\Service;
use App\Enums\Role;
use App\Events\BeforeOrganizationDeletion;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Models\Audit;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
@@ -183,8 +182,6 @@ class DeletionServiceTest extends TestCaseWithDatabase
// Assert
$this->assertOrganizationDeleted($organization->organization);
$this->assertOrganizationNothingDeleted($otherOrganization->organization);
$this->assertSame(0, Audit::query()->where('owner_organization_id', $organization->organization->getKey())->count());
$this->assertGreaterThan(0, Audit::query()->where('owner_organization_id', $otherOrganization->organization->getKey())->count());
Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'
&& $log->message === 'Start deleting organization'
&& $log->context['organization_id'] === $organization->organization->getKey(),
@@ -321,10 +318,6 @@ class DeletionServiceTest extends TestCaseWithDatabase
$this->assertDatabaseMissing(Member::class, [
'user_id' => $user->getKey(),
]);
$this->assertSame(0, Audit::query()->where('owner_user_id', $user->getKey())->count());
$this->assertSame(0, Audit::query()->where('owner_organization_id', $user->current_team_id)->count());
$this->assertGreaterThan(0, Audit::query()->where('owner_user_id', $otherUser->getKey())->count());
$this->assertGreaterThan(0, Audit::query()->where('owner_organization_id', $otherUser->current_team_id)->count());
Storage::disk(config('filesystems.public'))->assertMissing($user->profile_photo_path);
Storage::disk(config('filesystems.public'))->assertExists($otherUser->profile_photo_path);
Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'
@@ -339,24 +332,6 @@ class DeletionServiceTest extends TestCaseWithDatabase
);
}
public function test_delete_user_keeps_audits_of_other_organizations_where_the_user_is_the_actor(): void
{
// Arrange
$user = User::factory()->withPersonalOrganization()->create();
$otherOrganization = Organization::factory()->create();
$audit = Audit::factory()->auditUser($user)->auditFor($otherOrganization)->create([
'owner_organization_id' => $otherOrganization->getKey(),
]);
// Act
$this->deletionService->deleteUser($user);
// Assert
$audit->refresh();
$this->assertSame($user->getKey(), $audit->actor_id);
$this->assertSame($otherOrganization->getKey(), $audit->owner_organization_id);
}
public function test_delete_user_deletes_owned_organizations_that_have_only_one_member_and_makes_makes_the_user_placeholder_in_not_owned_organizations(): void
{
// Arrange